pgai 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c342a96f17431de20953406ba211771e0b7647f6ec7e59377e50399d89ca63bc
4
+ data.tar.gz: e1737c3f34f8b2b832c9706019eec6169fb723eb27ffc260acd285a8e24fce1e
5
+ SHA512:
6
+ metadata.gz: e1981687bdf6204b16e651f6313c3db1eb8e9691817b23f49c4f81611163a9ac3df7588ffe2b58dbae5c4adcd75e924ea6e2c4835fa24c29bc73421d7a07c027
7
+ data.tar.gz: 7a9a3fe849f9d5cb1d010fba31900f07ff0f10413f222a329a080d3517b44aa6b870f09e7d9995bbff573cf434ba09f4364126df3e8831df5938a69932fbb4e2
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2023 Marius Bobin
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # Pgai
2
+
3
+ Simple CLI wrapper around Postgres.ai CLI to drop directly into a PSQL session.
4
+
5
+ ## Installation
6
+
7
+ Install the gem by executing:
8
+
9
+ ```shell
10
+ gem install pgai
11
+ ```
12
+
13
+ ## Configuration
14
+
15
+ For an overview of available commands, execute:
16
+
17
+ ```shell
18
+ pgai -h
19
+ ```
20
+
21
+ Before usage `pgai config` must be executed and at least an environment must be configured with `pgai env add`
22
+
23
+ Postgres.ai does not provide `dblab` binaries for `ARM` processors(yet?), to work around this:
24
+
25
+ - clone `https://gitlab.com/postgres-ai/database-lab`
26
+ - change [`GOARCH = amd64`](https://gitlab.com/postgres-ai/database-lab/-/blob/0ad2cdd8f1d851e4beda92e0577bb242c6a8dd7f/engine/Makefile#L6) to `GOARCH = arm64`
27
+ - execute [`make build`](https://gitlab.com/postgres-ai/database-lab/-/blob/0ad2cdd8f1d851e4beda92e0577bb242c6a8dd7f/engine/Makefile#L44-L47), we're interested only about building `CLI_BINARY`, the others could be removed.
28
+ - the `dblab` binary should be available in the `bin` directory
29
+ - run `pgai config --exe /path/to/bin/dblab` to set the path if it's not in `$PATH`.
30
+
31
+ ## Usage
32
+
33
+ ```shell
34
+ pgai connect <env alias>
35
+ ```
36
+
37
+ Multiple `connect` commands for an environment will connect to the same clone, it won't start a new one.
38
+
39
+ ## Contributing
40
+
41
+ Bug reports and pull requests are welcome on GitLab at https://gitlab.com/mbobin/pgai.
data/bin/pgai ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ Thread.report_on_exception = false
5
+
6
+ require_relative "../lib/pgai"
7
+
8
+ Pgai::Cli::Main.start(ARGV)
@@ -0,0 +1,21 @@
1
+ require "thor"
2
+
3
+ module Pgai::Cli
4
+ class Base < Thor
5
+ def self.exit_on_failure?
6
+ true
7
+ end
8
+
9
+ private
10
+
11
+ def configuration
12
+ @configuration ||= load_and_validate_config!
13
+ end
14
+
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
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ module Pgai::Cli
2
+ class Env < Base
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
7
+ def add
8
+ configuration.add_env(**options)
9
+ end
10
+
11
+ 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
13
+ def remove
14
+ configuration.remove_env(options[:alias])
15
+ end
16
+
17
+ desc "list", "List all configured environments"
18
+ def list
19
+ puts JSON.pretty_generate(configuration.enviroments)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,31 @@
1
+ module Pgai::Cli
2
+ class Main < Base
3
+ desc "config", "Configure CLI options"
4
+ method_option :prefix, aliases: "-p", desc: "Specify prefix to be used for clones", required: true
5
+ method_option :dbname, aliases: "-n", desc: "Specify database name to connect to", required: true
6
+ method_option :proxy, aliases: "-x", desc: "Specify proxy host name", required: true
7
+ method_option :exe, aliases: "-e", desc: "dblab binary executable path", required: true
8
+ def config
9
+ token = ask("Access token:", echo: false)
10
+
11
+ Pgai::Config.persist(options.merge(token: token))
12
+ end
13
+
14
+ desc "connect database-name", "Create and connect to a thin clone database"
15
+ def connect(name)
16
+ env = configuration.enviroments.fetch(name)
17
+
18
+ Pgai::CloneManager.new(env, config: configuration).connect
19
+ end
20
+
21
+ desc "destroy database-name", "Remove the thin clone and all port forwards"
22
+ def destroy(name)
23
+ env = configuration.enviroments.fetch(name)
24
+
25
+ Pgai::CloneManager.new(env, config: configuration).cleanup
26
+ end
27
+
28
+ desc "env", "Manage environments"
29
+ subcommand "env", Pgai::Cli::Env
30
+ end
31
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "socket"
5
+ require "json"
6
+ require "securerandom"
7
+
8
+ module Pgai
9
+ class CloneManager
10
+ HOSTNAME = "127.0.0.1"
11
+
12
+ def initialize(environment, config:)
13
+ @environment = environment
14
+ @config = config
15
+ end
16
+
17
+ def connect
18
+ configure_enviroment
19
+
20
+ psql find_or_create_clone
21
+ end
22
+
23
+ def cleanup
24
+ configure_enviroment
25
+ return unless find_raw_clone
26
+
27
+ dblab("clone", "destroy", clone_id, raw: true)
28
+ config.remove_clone(clone_id)
29
+ stop_port_forwards
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :environment, :config
35
+
36
+ def configure_enviroment
37
+ start_port_forward(enviroment_port)
38
+
39
+ dblab("init", "--url", "http://#{HOSTNAME}:#{enviroment_port}", "--token", config.access_token, "--environment-id", environment_id)
40
+ dblab("config", "switch", environment_id, raw: true)
41
+ end
42
+
43
+ def find_or_create_clone
44
+ find_clone || create_clone
45
+ end
46
+
47
+ def find_clone
48
+ raw_clone = find_raw_clone
49
+
50
+ if raw_clone
51
+ config.find_clone(clone_id) || raise(Pagi::CloneNotFount, "clone not tracked")
52
+ else
53
+ config.remove_clone(clone_id) && nil
54
+ end
55
+ end
56
+
57
+ def find_raw_clone
58
+ Array(dblab("clone", "list")).find { |clone| clone["id"] == clone_id }
59
+ end
60
+
61
+ def create_clone
62
+ raw_clone = dblab("clone", "create", "--id", clone_id, "--username", clone_user, "--password", clone_password)
63
+ raise "Could not create clone" unless raw_clone
64
+
65
+ attributes = {
66
+ port: raw_clone.dig("db", "port"),
67
+ password: clone_password,
68
+ user: clone_user,
69
+ host: HOSTNAME,
70
+ dbname: config.dbname
71
+ }
72
+
73
+ config.persist_clone(clone_id, attributes)
74
+ end
75
+
76
+ def psql(clone)
77
+ start_port_forward(clone.port)
78
+
79
+ psql_pid = fork do
80
+ wait_for_connections(clone.port)
81
+ exec("psql #{clone.connection_string}")
82
+ end
83
+
84
+ Process.wait(psql_pid)
85
+ end
86
+
87
+ def environment_id
88
+ environment.fetch(:id)
89
+ end
90
+
91
+ def enviroment_port
92
+ environment.fetch(:port)
93
+ end
94
+
95
+ def clone_id
96
+ @clone_id ||= [config.clone_prefix, environment.fetch(:alias)].join("_")
97
+ end
98
+
99
+ def clone_user
100
+ @clone_user ||= SecureRandom.hex(8)
101
+ end
102
+
103
+ def clone_password
104
+ @clone_user ||= SecureRandom.hex(16)
105
+ end
106
+
107
+ def dblab(*args, raw: false)
108
+ executable = config.path || "dblab"
109
+
110
+ output = `#{args.unshift(executable).shelljoin}`
111
+ return output if raw
112
+
113
+ JSON.parse(output) unless output.empty?
114
+ end
115
+
116
+ def start_port_forward(port)
117
+ return if port_open?(port)
118
+
119
+ system("ssh -fNTML #{port}:#{HOSTNAME}:#{port} #{config.proxy}")
120
+ wait_for_connections(port)
121
+ end
122
+
123
+ def stop_port_forwards
124
+ raw_pids = `ps ax | grep 'ssh -fNTML' | grep '#{config.proxy}' | grep -v grep | awk '{ print $1 }'`
125
+
126
+ raw_pids.split.map do |pid|
127
+ Process.kill("HUP", pid.to_i)
128
+ end
129
+ end
130
+
131
+ def port_open?(port)
132
+ !!TCPSocket.new(HOSTNAME, port)
133
+ rescue Errno::ECONNREFUSED
134
+ false
135
+ end
136
+
137
+ def wait_for_connections(port)
138
+ until port_open?(port)
139
+ sleep 0.02
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "pstore"
5
+
6
+ module Pgai
7
+ class Config
8
+ Clone = Struct.new(:host, :port, :password, :user, :dbname, keyword_init: true) do
9
+ def connection_string
10
+ "'host=#{host} port=#{port} user=#{user} dbname=#{dbname} password=#{password}'"
11
+ end
12
+ end
13
+
14
+ attr_accessor :clone_prefix
15
+ attr_accessor :dbname
16
+ attr_accessor :access_token
17
+ attr_accessor :proxy
18
+ attr_accessor :path
19
+
20
+ def self.load
21
+ new { |config| config.load_from_store }
22
+ end
23
+
24
+ def self.persist(data)
25
+ new { |config| config.write(data) }
26
+ end
27
+
28
+ def initialize
29
+ yield self if block_given?
30
+ end
31
+
32
+ def load_from_store
33
+ store.transaction(true) do
34
+ self.clone_prefix = store[:clone_prefix]
35
+ self.dbname = store[:dbname]
36
+ self.access_token = store[:access_token]
37
+ self.proxy = store[:proxy]
38
+ self.path = store[:path]
39
+ end
40
+ end
41
+
42
+ def write(options)
43
+ store.transaction do
44
+ store[:clone_prefix] = options[:prefix]
45
+ store[:dbname] = options[:dbname]
46
+ store[:access_token] = options[:token]
47
+ store[:proxy] = options[:proxy]
48
+ store[:path] = options[:path]
49
+ store[:clones] = {}
50
+ end
51
+ end
52
+
53
+ def find_clone(id)
54
+ store.transaction(true) do
55
+ clones = store[:clones] || {}
56
+
57
+ Clone.new(clones[id]) if clones[id]
58
+ end
59
+ end
60
+
61
+ def remove_clone(id)
62
+ store.transaction do
63
+ store[:clones] ||= {}
64
+ store[:clones] = store[:clones].except(id)
65
+ end
66
+ end
67
+
68
+ def persist_clone(id, attributes = {})
69
+ store.transaction do
70
+ store[:clones] = {}
71
+ store[:clones].merge!(id => attributes)
72
+ end
73
+
74
+ Clone.new(attributes)
75
+ end
76
+
77
+ def add_env(options = {})
78
+ store.transaction do
79
+ store[:environments] ||= {}
80
+ store[:environments].merge!(options[:alias] => options)
81
+ end
82
+ end
83
+
84
+ def remove_env(env_alias)
85
+ store.transaction do
86
+ store[:environments] ||= {}
87
+ store[:environments] = store[:environments].except(env_alias)
88
+ end
89
+ end
90
+
91
+ def enviroments
92
+ store.transaction(true) do
93
+ store[:environments] || {}
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def store
100
+ @store ||= PStore.new(store_path)
101
+ end
102
+
103
+ def store_path
104
+ @store_path ||= Pathname("~/.config/pgai/config.pstore").expand_path.tap do |path|
105
+ FileUtils.mkdir_p File.dirname(path)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,3 @@
1
+ module Pgai
2
+ VERSION = "0.1.1"
3
+ end
data/lib/pgai.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pgai/version"
4
+ require_relative "pgai/config"
5
+ require_relative "pgai/clone_manager"
6
+ require_relative "pgai/cli/base"
7
+ require_relative "pgai/cli/env"
8
+ require_relative "pgai/cli/main"
9
+
10
+ module Pgai
11
+ class Error < StandardError; end
12
+
13
+ class CloneNotFount < Error; end
14
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pgai
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Marius Bobin
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-04-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: thor
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.2'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 1.2.1
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '1.2'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.2.1
33
+ - !ruby/object:Gem::Dependency
34
+ name: rake
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rspec
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: standard
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.3'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.3'
75
+ description:
76
+ email:
77
+ - mbobin@gitlab.com
78
+ executables:
79
+ - pgai
80
+ extensions: []
81
+ extra_rdoc_files: []
82
+ files:
83
+ - MIT-LICENSE
84
+ - README.md
85
+ - bin/pgai
86
+ - lib/pgai.rb
87
+ - lib/pgai/cli/base.rb
88
+ - lib/pgai/cli/env.rb
89
+ - lib/pgai/cli/main.rb
90
+ - lib/pgai/clone_manager.rb
91
+ - lib/pgai/config.rb
92
+ - lib/pgai/version.rb
93
+ homepage: https://gitlab.com/mbobin/pgai
94
+ licenses:
95
+ - MIT
96
+ metadata: {}
97
+ post_install_message:
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: 3.0.0
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubygems_version: 3.4.1
113
+ signing_key:
114
+ specification_version: 4
115
+ summary: CLI wrapper for postgres.ai thin clones
116
+ test_files: []