pgai 0.1.4 → 0.1.5
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 +4 -4
- data/README.md +23 -2
- data/lib/pgai/clone_manager.rb +18 -80
- data/lib/pgai/dblab.rb +78 -0
- data/lib/pgai/port_forward.rb +66 -0
- data/lib/pgai/version.rb +1 -1
- data/lib/pgai.rb +2 -0
- metadata +4 -2
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: a31b2fc8c0e25d189cd3f3191a8e1833de2cbe8f21dfe5e4322793fea1aa4efd
         | 
| 4 | 
            +
              data.tar.gz: 6061e6095d92912eb7d5a2f64fe95740314494ff23aefab32afe4e9b15d77d3b
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: ee38e79b9be45e6a848659a830550ce0cf7ad3cf272fc2c6d6e9ddb95214595ae9fd506a4726d3fffe36719e56a4d4bf439ff8543099ee457feed13e5a6ebbc6
         | 
| 7 | 
            +
              data.tar.gz: b3d34902baa84fc445e10d1874455b3168fe3896c78fd83d41b84c24d78ee1bdc9ccf02282b984d848cdf2984bc8f4ca136b43ff367c9a0857857d164b554358
         | 
    
        data/README.md
    CHANGED
    
    | @@ -22,10 +22,24 @@ Before usage `pgai config` must be executed and at least an environment must be | |
| 22 22 |  | 
| 23 23 | 
             
            An access token will be required and it can be obtained from: https://console.postgres.ai/gitlab/tokens
         | 
| 24 24 |  | 
| 25 | 
            -
             | 
| 25 | 
            +
            Example:
         | 
| 26 26 |  | 
| 27 | 
            +
            ```shell
         | 
| 28 | 
            +
            pgai config --dbname=gitlabhq_dblab --prefix=<gitlab handle> --proxy=<domain> --exe=<optional /path/to/dblab>
         | 
| 29 | 
            +
            ```
         | 
| 30 | 
            +
             | 
| 31 | 
            +
            To configure environments:
         | 
| 32 | 
            +
             | 
| 33 | 
            +
            ```shell
         | 
| 34 | 
            +
            pgai env add --alias ci --id ci-database --port 12345
         | 
| 27 35 | 
             
            ```
         | 
| 28 | 
            -
             | 
| 36 | 
            +
             | 
| 37 | 
            +
            The environment id, port, and proxy domain can be found by clicking on the `Connect` button on a database instance page.
         | 
| 38 | 
            +
             | 
| 39 | 
            +
            Configuring the user and identity file for the proxy domain must be done using the `~/.ssh/config` file. Example:
         | 
| 40 | 
            +
             | 
| 41 | 
            +
            ```
         | 
| 42 | 
            +
            Host <domain>
         | 
| 29 43 | 
             
              IdentityFile ~/.ssh/id_ed25519
         | 
| 30 44 | 
             
              User <username>
         | 
| 31 45 | 
             
            ```
         | 
| @@ -48,6 +62,13 @@ pgai connect <env alias> | |
| 48 62 |  | 
| 49 63 | 
             
            Multiple `connect` commands for an environment will connect to the same clone, it won't start a new one.
         | 
| 50 64 |  | 
| 65 | 
            +
            ### Features
         | 
| 66 | 
            +
             | 
| 67 | 
            +
            - multiple psql sessions to the same clone
         | 
| 68 | 
            +
            - multiple environments support
         | 
| 69 | 
            +
            - automatic port forward management
         | 
| 70 | 
            +
            - prevents system sleep while psql sessions are active via `caffeinate`
         | 
| 71 | 
            +
             | 
| 51 72 | 
             
            ## Contributing
         | 
| 52 73 |  | 
| 53 74 | 
             
            Bug reports and pull requests are welcome on GitLab at https://gitlab.com/mbobin/pgai.
         | 
    
        data/lib/pgai/clone_manager.rb
    CHANGED
    
    | @@ -1,23 +1,14 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 | 
            -
            require "shellwords"
         | 
| 4 | 
            -
            require "socket"
         | 
| 5 | 
            -
            require "json"
         | 
| 6 | 
            -
            require "securerandom"
         | 
| 7 | 
            -
            require "net/http"
         | 
| 8 | 
            -
            require "pathname"
         | 
| 9 | 
            -
            require "fileutils"
         | 
| 10 | 
            -
             | 
| 11 3 | 
             
            module Pgai
         | 
| 12 4 | 
             
              class CloneManager
         | 
| 13 5 | 
             
                HOSTNAME = "127.0.0.1"
         | 
| 14 | 
            -
                DEFAULT_DBLAB = Pathname.new("~/.dblab/dblab").expand_path
         | 
| 15 | 
            -
                DBLAB_RELEASE_CHANNEL = "master"
         | 
| 16 | 
            -
                DBLAB_BINARY_URL = "https://storage.googleapis.com/database-lab-cli/#{DBLAB_RELEASE_CHANNEL}/dblab-%{os}-%{cpu}"
         | 
| 17 6 |  | 
| 18 7 | 
             
                def initialize(environment, config:)
         | 
| 19 8 | 
             
                  @environment = environment
         | 
| 20 9 | 
             
                  @config = config
         | 
| 10 | 
            +
                  @port_forward = PortForward.new(config: config, hostname: HOSTNAME)
         | 
| 11 | 
            +
                  @dblab = Dblab.new(config: config, hostname: HOSTNAME)
         | 
| 21 12 | 
             
                end
         | 
| 22 13 |  | 
| 23 14 | 
             
                def connect
         | 
| @@ -30,20 +21,18 @@ module Pgai | |
| 30 21 | 
             
                  configure_enviroment
         | 
| 31 22 | 
             
                  return unless find_raw_clone
         | 
| 32 23 |  | 
| 33 | 
            -
                  dblab( | 
| 24 | 
            +
                  dblab.destroy_clone(id: clone_id)
         | 
| 34 25 | 
             
                  config.remove_clone(clone_id)
         | 
| 35 | 
            -
                   | 
| 26 | 
            +
                  port_forward.stop
         | 
| 36 27 | 
             
                end
         | 
| 37 28 |  | 
| 38 29 | 
             
                private
         | 
| 39 30 |  | 
| 40 | 
            -
                attr_reader :environment, :config
         | 
| 31 | 
            +
                attr_reader :environment, :config, :port_forward, :dblab
         | 
| 41 32 |  | 
| 42 33 | 
             
                def configure_enviroment
         | 
| 43 | 
            -
                   | 
| 44 | 
            -
             | 
| 45 | 
            -
                  dblab("init", "--url", "http://#{HOSTNAME}:#{enviroment_port}", "--token", config.access_token, "--environment-id", environment_id)
         | 
| 46 | 
            -
                  dblab("config", "switch", environment_id, raw: true)
         | 
| 34 | 
            +
                  port_forward.start(enviroment_port)
         | 
| 35 | 
            +
                  dblab.configure_env(port: enviroment_port, token: config.access_token, id: environment_id)
         | 
| 47 36 | 
             
                end
         | 
| 48 37 |  | 
| 49 38 | 
             
                def find_or_create_clone
         | 
| @@ -61,12 +50,11 @@ module Pgai | |
| 61 50 | 
             
                end
         | 
| 62 51 |  | 
| 63 52 | 
             
                def find_raw_clone
         | 
| 64 | 
            -
                   | 
| 53 | 
            +
                  dblab.list_clones.find { |clone| clone["id"] == clone_id }
         | 
| 65 54 | 
             
                end
         | 
| 66 55 |  | 
| 67 56 | 
             
                def create_clone
         | 
| 68 | 
            -
                  raw_clone = dblab( | 
| 69 | 
            -
                  raise "Could not create clone" unless raw_clone
         | 
| 57 | 
            +
                  raw_clone = dblab.create_clone(id: clone_id, user: clone_user, password: clone_password)
         | 
| 70 58 |  | 
| 71 59 | 
             
                  attributes = {
         | 
| 72 60 | 
             
                    port: raw_clone.dig("db", "port"),
         | 
| @@ -80,14 +68,16 @@ module Pgai | |
| 80 68 | 
             
                end
         | 
| 81 69 |  | 
| 82 70 | 
             
                def psql(clone)
         | 
| 83 | 
            -
                   | 
| 71 | 
            +
                  port_forward.start(clone.port)
         | 
| 84 72 |  | 
| 85 73 | 
             
                  psql_pid = fork do
         | 
| 86 | 
            -
                     | 
| 74 | 
            +
                    start_caffeinate(Process.pid)
         | 
| 87 75 | 
             
                    exec("psql #{clone.connection_string}")
         | 
| 88 76 | 
             
                  end
         | 
| 89 | 
            -
             | 
| 90 77 | 
             
                  Process.wait(psql_pid)
         | 
| 78 | 
            +
                ensure
         | 
| 79 | 
            +
                  port_forward.conditionally_stop(clone.port, [psql_pid])
         | 
| 80 | 
            +
                  port_forward.conditionally_stop(enviroment_port)
         | 
| 91 81 | 
             
                end
         | 
| 92 82 |  | 
| 93 83 | 
             
                def environment_id
         | 
| @@ -110,63 +100,11 @@ module Pgai | |
| 110 100 | 
             
                  @clone_user ||= SecureRandom.hex(16)
         | 
| 111 101 | 
             
                end
         | 
| 112 102 |  | 
| 113 | 
            -
                def  | 
| 114 | 
            -
                   | 
| 115 | 
            -
                  return output if raw
         | 
| 116 | 
            -
             | 
| 117 | 
            -
                  JSON.parse(output) unless output.empty?
         | 
| 118 | 
            -
                end
         | 
| 119 | 
            -
             | 
| 120 | 
            -
                def dblab_path
         | 
| 121 | 
            -
                  return config.path unless config.path.to_s.empty?
         | 
| 122 | 
            -
                  return "dblab" unless `which dblab`.to_s.empty?
         | 
| 123 | 
            -
                  return DEFAULT_DBLAB.to_s if DEFAULT_DBLAB.exist?
         | 
| 124 | 
            -
             | 
| 125 | 
            -
                  download_dblab_to(DEFAULT_DBLAB)
         | 
| 126 | 
            -
                  File.chmod(0o755, DEFAULT_DBLAB)
         | 
| 127 | 
            -
             | 
| 128 | 
            -
                  DEFAULT_DBLAB
         | 
| 129 | 
            -
                end
         | 
| 130 | 
            -
             | 
| 131 | 
            -
                def download_dblab_to(location)
         | 
| 132 | 
            -
                  platform = Gem::Platform.local
         | 
| 133 | 
            -
                  uri = URI(DBLAB_BINARY_URL % {os: platform.os, cpu: platform.cpu})
         | 
| 134 | 
            -
             | 
| 135 | 
            -
                  Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
         | 
| 136 | 
            -
                    http.request Net::HTTP::Get.new(uri) do |response|
         | 
| 137 | 
            -
                      FileUtils.mkdir_p File.dirname(location)
         | 
| 138 | 
            -
                      File.open(location, "w") do |io|
         | 
| 139 | 
            -
                        response.read_body { |chunk| io.write chunk }
         | 
| 140 | 
            -
                      end
         | 
| 141 | 
            -
                    end
         | 
| 142 | 
            -
                  end
         | 
| 143 | 
            -
                end
         | 
| 144 | 
            -
             | 
| 145 | 
            -
                def start_port_forward(port)
         | 
| 146 | 
            -
                  return if port_open?(port)
         | 
| 147 | 
            -
             | 
| 148 | 
            -
                  system("ssh -fNTML #{port}:#{HOSTNAME}:#{port} #{config.proxy}")
         | 
| 149 | 
            -
                  wait_for_connections(port)
         | 
| 150 | 
            -
                end
         | 
| 151 | 
            -
             | 
| 152 | 
            -
                def stop_port_forwards
         | 
| 153 | 
            -
                  raw_pids = `ps ax | grep 'ssh -fNTML' | grep '#{config.proxy}' | grep -v grep | awk '{ print $1 }'`
         | 
| 103 | 
            +
                def start_caffeinate(pid)
         | 
| 104 | 
            +
                  return if `which caffeinate`.to_s.empty?
         | 
| 154 105 |  | 
| 155 | 
            -
                   | 
| 156 | 
            -
             | 
| 157 | 
            -
                  end
         | 
| 158 | 
            -
                end
         | 
| 159 | 
            -
             | 
| 160 | 
            -
                def port_open?(port)
         | 
| 161 | 
            -
                  !!TCPSocket.new(HOSTNAME, port)
         | 
| 162 | 
            -
                rescue Errno::ECONNREFUSED
         | 
| 163 | 
            -
                  false
         | 
| 164 | 
            -
                end
         | 
| 165 | 
            -
             | 
| 166 | 
            -
                def wait_for_connections(port)
         | 
| 167 | 
            -
                  until port_open?(port)
         | 
| 168 | 
            -
                    sleep 0.02
         | 
| 169 | 
            -
                  end
         | 
| 106 | 
            +
                  caffeinate_pid = Process.spawn("caffeinate -is -w #{pid}")
         | 
| 107 | 
            +
                  Process.detach(caffeinate_pid)
         | 
| 170 108 | 
             
                end
         | 
| 171 109 | 
             
              end
         | 
| 172 110 | 
             
            end
         | 
    
        data/lib/pgai/dblab.rb
    ADDED
    
    | @@ -0,0 +1,78 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "shellwords"
         | 
| 4 | 
            +
            require "json"
         | 
| 5 | 
            +
            require "net/http"
         | 
| 6 | 
            +
            require "pathname"
         | 
| 7 | 
            +
            require "fileutils"
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            module Pgai
         | 
| 10 | 
            +
              class Dblab
         | 
| 11 | 
            +
                DEFAULT_DBLAB = Pathname.new("~/.dblab/dblab").expand_path
         | 
| 12 | 
            +
                DBLAB_RELEASE_CHANNEL = "master"
         | 
| 13 | 
            +
                DBLAB_BINARY_URL = "https://storage.googleapis.com/database-lab-cli/#{DBLAB_RELEASE_CHANNEL}/dblab-%{os}-%{cpu}"
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                def initialize(config:, hostname:)
         | 
| 16 | 
            +
                  @config = config
         | 
| 17 | 
            +
                  @hostname = hostname
         | 
| 18 | 
            +
                end
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                def configure_env(port:, token:, id:)
         | 
| 21 | 
            +
                  dblab("init", "--url", "http://#{hostname}:#{port}", "--token", token, "--environment-id", id, silence: true)
         | 
| 22 | 
            +
                  dblab("config", "switch", id, raw: true)
         | 
| 23 | 
            +
                end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                def list_clones
         | 
| 26 | 
            +
                  Array(dblab("clone", "list"))
         | 
| 27 | 
            +
                end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                def create_clone(id:, user:, password:)
         | 
| 30 | 
            +
                  data = dblab("clone", "create", "--id", id, "--username", user, "--password", password)
         | 
| 31 | 
            +
                  raise "Could not create clone" unless data
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                  data
         | 
| 34 | 
            +
                end
         | 
| 35 | 
            +
             | 
| 36 | 
            +
                def destroy_clone(id:)
         | 
| 37 | 
            +
                  dblab("clone", "destroy", id, raw: true)
         | 
| 38 | 
            +
                end
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                private
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                attr_reader :config, :hostname
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                def dblab(*args, raw: false, silence: false)
         | 
| 45 | 
            +
                  redirect = "2>/dev/null" if silence
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                  output = `#{args.unshift(dblab_path).shelljoin} #{redirect}`
         | 
| 48 | 
            +
                  return output if raw
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                  JSON.parse(output) unless output.empty?
         | 
| 51 | 
            +
                end
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                def dblab_path
         | 
| 54 | 
            +
                  return config.path unless config.path.to_s.empty?
         | 
| 55 | 
            +
                  return "dblab" unless `which dblab`.to_s.empty?
         | 
| 56 | 
            +
                  return DEFAULT_DBLAB.to_s if DEFAULT_DBLAB.exist?
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                  download_dblab_to(DEFAULT_DBLAB)
         | 
| 59 | 
            +
                  File.chmod(0o755, DEFAULT_DBLAB)
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                  DEFAULT_DBLAB
         | 
| 62 | 
            +
                end
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                def download_dblab_to(location)
         | 
| 65 | 
            +
                  platform = Gem::Platform.local
         | 
| 66 | 
            +
                  uri = URI(DBLAB_BINARY_URL % {os: platform.os, cpu: platform.cpu})
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                  Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
         | 
| 69 | 
            +
                    http.request Net::HTTP::Get.new(uri) do |response|
         | 
| 70 | 
            +
                      FileUtils.mkdir_p File.dirname(location)
         | 
| 71 | 
            +
                      File.open(location, "w") do |io|
         | 
| 72 | 
            +
                        response.read_body { |chunk| io.write chunk }
         | 
| 73 | 
            +
                      end
         | 
| 74 | 
            +
                    end
         | 
| 75 | 
            +
                  end
         | 
| 76 | 
            +
                end
         | 
| 77 | 
            +
              end
         | 
| 78 | 
            +
            end
         | 
| @@ -0,0 +1,66 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "shellwords"
         | 
| 4 | 
            +
            require "socket"
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module Pgai
         | 
| 7 | 
            +
              class PortForward
         | 
| 8 | 
            +
                def initialize(config:, hostname:)
         | 
| 9 | 
            +
                  @config = config
         | 
| 10 | 
            +
                  @hostname = hostname
         | 
| 11 | 
            +
                end
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                def start(port)
         | 
| 14 | 
            +
                  return if ready?(port)
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  system("ssh -fNTML #{port.to_i}:#{escape hostname}:#{port.to_i} #{escape config.proxy}")
         | 
| 17 | 
            +
                  wait_until_ready(port)
         | 
| 18 | 
            +
                end
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                def stop(port = nil)
         | 
| 21 | 
            +
                  port_forward_pids(port).each do |pid|
         | 
| 22 | 
            +
                    Process.kill("HUP", pid.to_i)
         | 
| 23 | 
            +
                  end
         | 
| 24 | 
            +
                end
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                def conditionally_stop(port, ignored_pids = [])
         | 
| 27 | 
            +
                  pids = lsof(port, ignored_pids + [Process.pid])
         | 
| 28 | 
            +
                  pf_pids = port_forward_pids(port)
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                  stop(port) if (pids - pf_pids).empty?
         | 
| 31 | 
            +
                end
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                private
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                attr_reader :config, :hostname
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                def ready?(port)
         | 
| 38 | 
            +
                  !!TCPSocket.new(hostname, port)
         | 
| 39 | 
            +
                rescue Errno::ECONNREFUSED
         | 
| 40 | 
            +
                  false
         | 
| 41 | 
            +
                end
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                def wait_until_ready(port)
         | 
| 44 | 
            +
                  until ready?(port)
         | 
| 45 | 
            +
                    sleep 0.02
         | 
| 46 | 
            +
                  end
         | 
| 47 | 
            +
                end
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                def port_forward_pids(port_filter = nil)
         | 
| 50 | 
            +
                  ssh_filter = escape "ssh -fNTML #{port_filter&.to_i}".strip
         | 
| 51 | 
            +
                  host_filter = escape config.proxy
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                  `ps ax | grep #{ssh_filter} | grep #{host_filter} | grep -v grep | awk '{ print $1 }'`.split.map(&:to_i)
         | 
| 54 | 
            +
                end
         | 
| 55 | 
            +
             | 
| 56 | 
            +
                def lsof(port, exclude_pids = [])
         | 
| 57 | 
            +
                  ignored_pids = exclude_pids.map { |pid| "-p^#{pid.to_i}" }.join(" ")
         | 
| 58 | 
            +
             | 
| 59 | 
            +
                  `lsof -i:#{port.to_i} #{ignored_pids} -t`.split.map(&:to_i)
         | 
| 60 | 
            +
                end
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                def escape(...)
         | 
| 63 | 
            +
                  Shellwords.escape(...)
         | 
| 64 | 
            +
                end
         | 
| 65 | 
            +
              end
         | 
| 66 | 
            +
            end
         | 
    
        data/lib/pgai/version.rb
    CHANGED
    
    
    
        data/lib/pgai.rb
    CHANGED
    
    
    
        metadata
    CHANGED
    
    | @@ -1,14 +1,14 @@ | |
| 1 1 | 
             
            --- !ruby/object:Gem::Specification
         | 
| 2 2 | 
             
            name: pgai
         | 
| 3 3 | 
             
            version: !ruby/object:Gem::Version
         | 
| 4 | 
            -
              version: 0.1. | 
| 4 | 
            +
              version: 0.1.5
         | 
| 5 5 | 
             
            platform: ruby
         | 
| 6 6 | 
             
            authors:
         | 
| 7 7 | 
             
            - Marius Bobin
         | 
| 8 8 | 
             
            autorequire:
         | 
| 9 9 | 
             
            bindir: bin
         | 
| 10 10 | 
             
            cert_chain: []
         | 
| 11 | 
            -
            date: 2023-04- | 
| 11 | 
            +
            date: 2023-04-25 00:00:00.000000000 Z
         | 
| 12 12 | 
             
            dependencies:
         | 
| 13 13 | 
             
            - !ruby/object:Gem::Dependency
         | 
| 14 14 | 
             
              name: thor
         | 
| @@ -89,6 +89,8 @@ files: | |
| 89 89 | 
             
            - lib/pgai/cli/main.rb
         | 
| 90 90 | 
             
            - lib/pgai/clone_manager.rb
         | 
| 91 91 | 
             
            - lib/pgai/config.rb
         | 
| 92 | 
            +
            - lib/pgai/dblab.rb
         | 
| 93 | 
            +
            - lib/pgai/port_forward.rb
         | 
| 92 94 | 
             
            - lib/pgai/version.rb
         | 
| 93 95 | 
             
            homepage: https://gitlab.com/mbobin/pgai
         | 
| 94 96 | 
             
            licenses:
         |