bindan 0.1.1 → 0.1.2
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/CHANGELOG.md +22 -0
- data/README.md +38 -17
- data/Rakefile +7 -1
- data/compose.yml +16 -2
- data/lib/bindan/emulators/firestore_controller.rb +87 -117
- data/lib/bindan/emulators/gcs_server_controller.rb +19 -82
- data/lib/bindan/providers/firestore.rb +28 -9
- data/lib/bindan/providers/storage.rb +18 -3
- data/lib/bindan/version.rb +1 -1
- data/sig/bindan/emulators/firestore_controller.rbs +20 -49
- data/sig/bindan/emulators/gcs_server_controller.rbs +8 -45
- data/sig/bindan/providers/firestore.rbs +2 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b9be963a07d9adaf6952ec6a57c47ee853e20e601608e5e8d15bdbd9b23ab8b9
|
|
4
|
+
data.tar.gz: 4a23535e819cee5eea21bf664df8c25ab4ea7dc4fa9714e814a2b9e0153d4bb7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 41ff4d4d2d7c3467ee888a5c4b116ac47e7b2bb19764e2026b7d8c797f10fed4c7470cacfd663771c47dc5cfff28602427080872434f2bd269e80c3d7f4952f4
|
|
7
|
+
data.tar.gz: 271b943d0837381c9c11dd9169771116c47cdda6caad9c0d51c7d91e2c95c1a4d06e51a97e53e3adfd1d1cc1f452949054511caa1ed34415b36404587a1eac6e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.1.2] - 2026-11-12
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Separate Docker-free unit tests from emulator-backed integration tests.
|
|
8
|
+
- Manage GCS and Firestore emulators with Docker Compose. CI starts and stops the services; local integration tests use externally started services.
|
|
9
|
+
- Clear Firestore test data between cases instead of restarting the emulator for each test.
|
|
10
|
+
- Extract Firestore document path resolution without changing lookup behavior.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Correct the multiple-Provider README example and clarify Storage bucket names and Firestore collection paths.
|
|
15
|
+
- Restore Cloud Storage integration coverage in CI and constrain development dependencies for Ruby 3.0 compatibility.
|
|
16
|
+
|
|
17
|
+
### Development compatibility
|
|
18
|
+
|
|
19
|
+
- Remove `bin/emulator` and controller startup/shutdown APIs. Use `docker compose up` / `down` instead; host Java and Google Cloud CLI are no longer required. Firestore import/export is no longer supported by the development tooling.
|
|
20
|
+
|
|
21
|
+
## [0.1.1] - 2025-08-07
|
|
22
|
+
|
|
23
|
+
- add Class-extend use
|
|
24
|
+
|
|
3
25
|
## [0.1.0] - 2025-08-03
|
|
4
26
|
|
|
5
27
|
- Initial release
|
data/README.md
CHANGED
|
@@ -82,14 +82,14 @@ and
|
|
|
82
82
|
|
|
83
83
|
```ruby
|
|
84
84
|
require "bindan"
|
|
85
|
-
require "google/
|
|
86
|
-
require "google/
|
|
85
|
+
require "google/cloud/storage"
|
|
86
|
+
require "google/cloud/firestore"
|
|
87
87
|
|
|
88
88
|
# Define your configuration providers
|
|
89
89
|
providers = {
|
|
90
90
|
env: Bindan::Provider::Envvar.new,
|
|
91
|
-
storage: Bindan::Provider::Storage.new(bucket: "my-
|
|
92
|
-
firestore: Bindan::Provider::Firestore.new(collection: "
|
|
91
|
+
storage: Bindan::Provider::Storage.new("config-bucket", project_id: "my-project"),
|
|
92
|
+
firestore: Bindan::Provider::Firestore.new("app-settings-collection", project_id: "my-project")
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
# Configure Bindan
|
|
@@ -108,24 +108,24 @@ puts config.feature_flags # => (Value loaded from Firestore)
|
|
|
108
108
|
|
|
109
109
|
In the `configure` block, you define how each configuration key is mapped to a provider. The provider name is `providers` Hash key.
|
|
110
110
|
|
|
111
|
+
Replace `my-project` with your Google Cloud project ID. Storage joins `project_id` and the bucket argument with `separator` (default: `"-"`), so this example reads the `api_key` object from the existing `my-project-config-bucket` bucket. Firestore reads the `app-settings-collection/feature_flags_document` document in the same project.
|
|
112
|
+
|
|
111
113
|
## Development
|
|
112
114
|
|
|
113
|
-
After checking out the repo, run `bin/setup` to install dependencies
|
|
115
|
+
After checking out the repo, run `bin/setup` to install development dependencies and pull the emulator images. It does not start the containers.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bin/setup
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
114
122
|
|
|
115
123
|
### Prerequisites for Development
|
|
116
124
|
|
|
117
|
-
|
|
125
|
+
The development environment includes the following tools for running heavy integration tests locally:
|
|
118
126
|
|
|
119
|
-
1. **
|
|
120
|
-
2. **
|
|
121
|
-
3. **Firestore Emulator:**
|
|
122
|
-
```bash
|
|
123
|
-
gcloud components install cloud-firestore-emulator
|
|
124
|
-
```
|
|
125
|
-
4. **fake-gcs-server:**
|
|
126
|
-
```bash
|
|
127
|
-
docker pull fsouza/fake-gcs-server
|
|
128
|
-
```
|
|
127
|
+
1. **Docker with Docker Compose:** [Get Docker](https://docs.docker.com/get-docker/), with its daemon running.
|
|
128
|
+
2. **Emulators:** `compose.yml` defines fake-gcs-server and the official Google Cloud CLI emulator image. Java and Firestore are provided inside the image; no host Java or Google Cloud CLI installation is required. Storage fixtures are mounted directly from `spec/support/storage`.
|
|
129
129
|
|
|
130
130
|
### Custom Provider
|
|
131
131
|
|
|
@@ -141,12 +141,33 @@ end
|
|
|
141
141
|
|
|
142
142
|
### Running Tests
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
Use the lightweight unit tests during normal development. They do not start emulators:
|
|
145
145
|
|
|
146
146
|
```bash
|
|
147
147
|
bundle exec rake spec
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
Run the heavy integration tests locally when verifying Provider behavior against the real SDKs and emulators. Start the containers explicitly before running the suite:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
docker compose up --detach --wait
|
|
154
|
+
bundle exec rake spec:integration
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The tests wait for readiness but never start or stop containers. Stop them yourself when no longer needed:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
docker compose down
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
CI runs both suites and manages Compose startup, logs and cleanup in the workflow, including cleanup after failures.
|
|
164
|
+
|
|
165
|
+
Integration tests run serially against Storage on `127.0.0.1:4443` and Firestore on
|
|
166
|
+
`127.0.0.1:8080`. Each Firestore case clears the `bindan-test` project's default
|
|
167
|
+
database before seeding data. Use these emulators for disposable test data only.
|
|
168
|
+
The Firestore controller waits for an SDK request to succeed, not merely a TCP
|
|
169
|
+
connection. The suite does not import or export Firestore data.
|
|
170
|
+
|
|
150
171
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
151
172
|
|
|
152
173
|
## Contributing
|
data/Rakefile
CHANGED
|
@@ -5,7 +5,13 @@ require "minitest/test_task"
|
|
|
5
5
|
|
|
6
6
|
Minitest::TestTask.create(:spec) do |t|
|
|
7
7
|
t.libs << "spec"
|
|
8
|
-
t.test_globs = "spec/**/*_spec.rb"
|
|
8
|
+
t.test_globs = Dir.glob("spec/**/*_spec.rb").reject { |path| path.start_with?("spec/integration/") }
|
|
9
|
+
t.warning = false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
Minitest::TestTask.create("spec:integration") do |t|
|
|
13
|
+
t.libs << "spec"
|
|
14
|
+
t.test_globs = "spec/integration/**/*_spec.rb"
|
|
9
15
|
t.warning = false
|
|
10
16
|
end
|
|
11
17
|
|
data/compose.yml
CHANGED
|
@@ -3,10 +3,24 @@ services:
|
|
|
3
3
|
image: fsouza/fake-gcs-server:latest
|
|
4
4
|
command: -scheme http -port 4443 -host 0.0.0.0
|
|
5
5
|
ports:
|
|
6
|
-
- "4443:4443"
|
|
6
|
+
- "127.0.0.1:4443:4443"
|
|
7
|
+
volumes:
|
|
8
|
+
- "./spec/support/storage:/data/-config:ro"
|
|
7
9
|
healthcheck:
|
|
8
10
|
# The fake-gcs-server is ready when we can query the bucket list endpoint.
|
|
9
|
-
test: ["CMD", "
|
|
11
|
+
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4443/storage/v1/b"]
|
|
10
12
|
interval: 1s
|
|
11
13
|
timeout: 5s
|
|
12
14
|
retries: 20
|
|
15
|
+
firestore:
|
|
16
|
+
image: gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators
|
|
17
|
+
command:
|
|
18
|
+
- gcloud
|
|
19
|
+
- emulators
|
|
20
|
+
- firestore
|
|
21
|
+
- start
|
|
22
|
+
- --host-port=0.0.0.0:8080
|
|
23
|
+
- --database-mode=firestore-native
|
|
24
|
+
- --edition=standard
|
|
25
|
+
ports:
|
|
26
|
+
- "127.0.0.1:8080:8080"
|
|
@@ -1,151 +1,121 @@
|
|
|
1
1
|
require_relative "../error"
|
|
2
|
-
require "
|
|
3
|
-
require "open3"
|
|
4
|
-
require "socket"
|
|
2
|
+
require "net/http"
|
|
5
3
|
require "timeout"
|
|
6
|
-
require "uri"
|
|
7
4
|
|
|
8
5
|
module Bindan
|
|
9
6
|
module Emulator
|
|
10
7
|
#
|
|
11
|
-
# Represents and controls a single instance of the Google Cloud Firestore emulator
|
|
12
|
-
# This class
|
|
13
|
-
# separating process management from the test execution flow.
|
|
8
|
+
# Represents and controls a single instance of the Google Cloud Firestore emulator service.
|
|
9
|
+
# This class checks readiness and clears test data without managing containers.
|
|
14
10
|
#
|
|
15
11
|
class FirestoreController
|
|
16
|
-
WAIT_TIMEOUT =
|
|
12
|
+
WAIT_TIMEOUT = 60 # seconds
|
|
17
13
|
INITIAL_BACKOFF_KEY_SEC = 0.1 # seconds
|
|
18
14
|
BACKOFF_MULTIPLIER = 1.5
|
|
19
15
|
|
|
20
|
-
class
|
|
21
|
-
#
|
|
22
|
-
# @return [Array<String, Integer>]
|
|
23
|
-
#
|
|
24
|
-
def host_and_port(host = nil, port = nil)
|
|
25
|
-
host, port = ENV["FIRESTORE_EMULATOR_HOST"].to_s.split(":") if ENV["FIRESTORE_EMULATOR_HOST"]
|
|
16
|
+
class Error < StandardError; end
|
|
26
17
|
|
|
27
|
-
|
|
28
|
-
port ||= 8080
|
|
18
|
+
attr_reader :project_id
|
|
29
19
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
# @param [String] import
|
|
37
|
-
# @param [String] export
|
|
38
|
-
# @param [String] host
|
|
39
|
-
# @param [Integer] port
|
|
40
|
-
# @param [bool] close_io
|
|
41
|
-
# @raise [Errno]
|
|
42
|
-
# @return [FirestoreEmulatorController] An instance to manage the emulator lifecycle.
|
|
43
|
-
#
|
|
44
|
-
def start(import: nil, export: nil, host: nil, port: nil, close_io: true)
|
|
45
|
-
host, port = host_and_port(host, port)
|
|
46
|
-
|
|
47
|
-
puts " Starting Firestore emulator ..." unless close_io
|
|
48
|
-
kill_process_if_already_exists(host: host, port: port, with_message: !close_io)
|
|
49
|
-
|
|
50
|
-
#
|
|
51
|
-
# This command is known to launch a background Java process and then exit.
|
|
52
|
-
# Trying keep process info with process group
|
|
53
|
-
#
|
|
54
|
-
cmd = "gcloud emulators firestore start --host-port=#{host}:#{port}"
|
|
55
|
-
cmd += " --import-data=#{import}" if import
|
|
56
|
-
cmd += " --export-on-exit=#{export}" if export
|
|
57
|
-
opts = close_io ? {out: "/dev/null", err: "/dev/null"} : {}
|
|
58
|
-
pid = Process.spawn(*cmd, pgroup: true, **opts) # steep:ignore
|
|
59
|
-
|
|
60
|
-
puts " Emulator process initiated." unless close_io
|
|
61
|
-
new(pid, host, port)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
#
|
|
65
|
-
# kill process group
|
|
66
|
-
#
|
|
67
|
-
# @param [Integer] pid
|
|
68
|
-
# @param [bool] with_message
|
|
69
|
-
# @return [void]
|
|
70
|
-
#
|
|
71
|
-
def stop(pid, with_message: true)
|
|
72
|
-
puts "\n=> Stopping Firestore emulator..." if with_message
|
|
73
|
-
|
|
74
|
-
begin
|
|
75
|
-
Process.kill("TERM", -pid)
|
|
76
|
-
Process.wait(-pid)
|
|
77
|
-
rescue Errno::ESRCH, Errno::ECHILD
|
|
78
|
-
# Process was already killed or doesn't exist, which is fine.
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
puts " Emulator process terminated." if with_message
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
#
|
|
85
|
-
# @param [Integer] host
|
|
86
|
-
# @param [Integer] port
|
|
87
|
-
#
|
|
88
|
-
def kill_process_if_already_exists(host:, port:, with_message: true)
|
|
89
|
-
TCPSocket.new(host, port).close
|
|
90
|
-
|
|
91
|
-
# `lsof -Fg -i:PORT` returns PIDs of the process listening on the port.
|
|
92
|
-
process_ids, = Open3.capture3(*"lsof -Fg -g -s TCP:LISTEN -i :#{port}".split(" "))
|
|
93
|
-
process_group = process_ids.lines.map(&:chomp).find { |id| id =~ /^g([0-9]+)/ }
|
|
94
|
-
|
|
95
|
-
if process_group
|
|
96
|
-
id = process_group[1..].to_i # strip first letter
|
|
97
|
-
puts " Found emulator process group #{id} on port #{port}. Terminating." if with_message
|
|
98
|
-
stop(id, with_message: false)
|
|
99
|
-
end
|
|
100
|
-
rescue Errno::ECONNREFUSED
|
|
101
|
-
# noop
|
|
102
|
-
end
|
|
103
|
-
end # of class methods
|
|
20
|
+
#
|
|
21
|
+
# Select the project used by the integration tests.
|
|
22
|
+
#
|
|
23
|
+
def initialize(project_id: "bindan-test")
|
|
24
|
+
@project_id = project_id
|
|
25
|
+
end
|
|
104
26
|
|
|
105
27
|
#
|
|
106
|
-
# @
|
|
28
|
+
# @return [String] SDK endpoint (host:port, without an HTTP scheme)
|
|
107
29
|
#
|
|
108
|
-
def
|
|
109
|
-
|
|
110
|
-
@host = host
|
|
111
|
-
@port = port
|
|
30
|
+
def endpoint
|
|
31
|
+
"127.0.0.1:8080"
|
|
112
32
|
end
|
|
113
33
|
|
|
114
34
|
#
|
|
115
|
-
#
|
|
35
|
+
# Wait for an SDK read, retrying false with bounded exponential backoff.
|
|
116
36
|
#
|
|
117
|
-
# @
|
|
37
|
+
# @return [self] after a successful SDK read
|
|
38
|
+
# @raise [Error] when the overall deadline expires, including during a check
|
|
118
39
|
# @param [number] timeout
|
|
119
40
|
# @param [number] backoff
|
|
120
41
|
# @param [number] multiplier
|
|
121
|
-
#
|
|
42
|
+
# Failures/interruption propagate; container lifecycle is managed externally.
|
|
122
43
|
#
|
|
123
44
|
def wait_available(timeout: WAIT_TIMEOUT, backoff: INITIAL_BACKOFF_KEY_SEC, multiplier: BACKOFF_MULTIPLIER, with_message: true)
|
|
124
|
-
|
|
45
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
46
|
+
@readiness_error = nil
|
|
47
|
+
puts " Waiting for Firestore SDK on #{endpoint}..." if with_message
|
|
48
|
+
loop do
|
|
49
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
50
|
+
raise Error, "Firestore readiness timed out: #{@readiness_error}" unless remaining > 0
|
|
51
|
+
return self if Timeout.timeout(remaining) { sdk_ready?([remaining, 2].min) }
|
|
52
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
53
|
+
sleep [backoff, remaining].min if remaining > 0
|
|
54
|
+
backoff *= multiplier
|
|
55
|
+
end
|
|
56
|
+
rescue Timeout::Error => error
|
|
57
|
+
raise Error, "Firestore readiness timed out: #{error.message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#
|
|
61
|
+
# Clear only this project's default database and wait for DELETE completion.
|
|
62
|
+
#
|
|
63
|
+
# @return [self] after HTTP 200, before the next test seeds any data
|
|
64
|
+
# @raise [Error] for an unsuccessful HTTP status
|
|
65
|
+
# HTTP/timeout errors propagate; no lifecycle or other-project operations occur.
|
|
66
|
+
#
|
|
67
|
+
def clear(timeout: 10)
|
|
68
|
+
path = "/emulator/v1/projects/#{project_id}/databases/(default)/documents"
|
|
69
|
+
response = Timeout.timeout(timeout) { delete_documents(path, timeout) }
|
|
70
|
+
raise Error, "Firestore clear #{project_id} failed: HTTP #{response.code}: #{response.body}" unless response.code == "200"
|
|
71
|
+
self
|
|
72
|
+
end
|
|
125
73
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
#
|
|
77
|
+
# Check a read through the V1 public API, with per-call timeout and no SDK retry.
|
|
78
|
+
# Keep this lower-level boundary here: high-level doc.get can retry past its timeout.
|
|
79
|
+
#
|
|
80
|
+
# @return [Boolean] true on a completed read (a missing document is also valid),
|
|
81
|
+
# false only for transient unavailable/deadline errors during startup
|
|
82
|
+
# Other SDK/configuration errors propagate; no document is written.
|
|
83
|
+
# Each attempt closes its channel; wait_available also enforces an overall deadline.
|
|
84
|
+
#
|
|
85
|
+
def sdk_ready?(timeout)
|
|
86
|
+
require "google/cloud/firestore/v1"
|
|
87
|
+
require "grpc"
|
|
88
|
+
channel = GRPC::Core::Channel.new(endpoint, {}, :this_channel_is_insecure)
|
|
89
|
+
client = Google::Cloud::Firestore::V1::Firestore::Client.new do |config|
|
|
90
|
+
config.endpoint = endpoint
|
|
91
|
+
config.credentials = channel
|
|
139
92
|
end
|
|
140
|
-
|
|
141
|
-
|
|
93
|
+
database = "projects/#{project_id}/databases/(default)"
|
|
94
|
+
client.batch_get_documents(
|
|
95
|
+
{database: database, documents: ["#{database}/documents/bindan-control/readiness"]},
|
|
96
|
+
{timeout: timeout, retry_policy: {retry_codes: []}}
|
|
97
|
+
).each { |_| }
|
|
98
|
+
@readiness_error = nil
|
|
99
|
+
true
|
|
100
|
+
rescue Google::Cloud::UnavailableError, Google::Cloud::DeadlineExceededError => error
|
|
101
|
+
@readiness_error = error
|
|
102
|
+
false
|
|
103
|
+
ensure
|
|
104
|
+
channel&.close
|
|
142
105
|
end
|
|
143
106
|
|
|
144
107
|
#
|
|
145
|
-
#
|
|
108
|
+
# Send clear's local DELETE without environment proxies or redirect following.
|
|
109
|
+
#
|
|
110
|
+
# @return [Net::HTTPResponse] the completed response; clear validates its status
|
|
111
|
+
# Connection and timeout errors propagate to the caller.
|
|
146
112
|
#
|
|
147
|
-
def
|
|
148
|
-
|
|
113
|
+
def delete_documents(path, timeout)
|
|
114
|
+
http = Net::HTTP.new("127.0.0.1", 8080, nil) # Explicitly disable environment proxies.
|
|
115
|
+
http.open_timeout = timeout
|
|
116
|
+
http.read_timeout = timeout
|
|
117
|
+
http.write_timeout = timeout
|
|
118
|
+
http.request(Net::HTTP::Delete.new(path))
|
|
149
119
|
end
|
|
150
120
|
end
|
|
151
121
|
end
|
|
@@ -1,89 +1,23 @@
|
|
|
1
1
|
require_relative "../error"
|
|
2
|
-
require "
|
|
2
|
+
require "net/http"
|
|
3
3
|
require "timeout"
|
|
4
|
-
require "open3"
|
|
5
4
|
|
|
6
5
|
module Bindan
|
|
7
6
|
module Emulator
|
|
8
7
|
class GcsServerController
|
|
9
8
|
class ContainerCannotOpenError < Error; end
|
|
10
9
|
|
|
11
|
-
CONTAINER_NAME = "gcs-server"
|
|
12
10
|
WAIT_TIMEOUT = 15 # seconds
|
|
13
11
|
INITIAL_BACKOFF_KEY_SEC = 0.1 # seconds
|
|
14
12
|
BACKOFF_MULTIPLIER = 1.5
|
|
15
13
|
|
|
16
|
-
class << self
|
|
17
|
-
#
|
|
18
|
-
# @raise [Errno]
|
|
19
|
-
# @param [String] folder
|
|
20
|
-
# @param [Integer] [port]
|
|
21
|
-
# @param [String] [name]
|
|
22
|
-
# @return [GcsServerController]
|
|
23
|
-
#
|
|
24
|
-
def start(folder:, port: 4443, name: CONTAINER_NAME, close_io: true)
|
|
25
|
-
FileUtils.mkdir_p(folder)
|
|
26
|
-
unless running?(name: name)
|
|
27
|
-
opts = close_io ? {out: "/dev/null", err: "/dev/null"} : {}
|
|
28
|
-
Process.spawn( # steep:ignore
|
|
29
|
-
*"docker run --rm --name #{name} -p #{port}:4443 -v #{folder}:/data fsouza/fake-gcs-server -scheme http".split(" "),
|
|
30
|
-
**opts
|
|
31
|
-
)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
new(name: name, folder: folder)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
#
|
|
38
|
-
# @raise [IOError]
|
|
39
|
-
# @param [String] name
|
|
40
|
-
# @return [bool]
|
|
41
|
-
#
|
|
42
|
-
def running?(name:)
|
|
43
|
-
_stdin, stdout, stderr, = Open3.popen3("docker container list -f name=#{name}")
|
|
44
|
-
|
|
45
|
-
begin
|
|
46
|
-
# container list not only headers
|
|
47
|
-
stdout.readlines.size > 1
|
|
48
|
-
rescue IOError => e
|
|
49
|
-
warn "in #{self}"
|
|
50
|
-
warn " " + stderr.read
|
|
51
|
-
raise ContainerCannotOpenError.new e
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
#
|
|
56
|
-
# @param [String] name
|
|
57
|
-
# @raise [Errno]
|
|
58
|
-
# @return [void]
|
|
59
|
-
#
|
|
60
|
-
def stop(name:)
|
|
61
|
-
`docker container stop #{name}`
|
|
62
|
-
end
|
|
63
|
-
end # of class methods
|
|
64
|
-
|
|
65
|
-
#
|
|
66
|
-
# @param [String] name
|
|
67
|
-
# @param [String] folder
|
|
68
14
|
#
|
|
69
|
-
def initialize(name:, folder:)
|
|
70
|
-
@container_name = name
|
|
71
|
-
@folder = folder
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
#
|
|
75
|
-
# @return
|
|
76
|
-
#
|
|
77
|
-
def stop
|
|
78
|
-
self.class.stop(name: @container_name)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
#
|
|
82
|
-
# @param [String] name
|
|
83
15
|
# @return [bool]
|
|
84
16
|
#
|
|
85
17
|
def running?
|
|
86
|
-
|
|
18
|
+
read_status.code == "200"
|
|
19
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout
|
|
20
|
+
false
|
|
87
21
|
end
|
|
88
22
|
|
|
89
23
|
#
|
|
@@ -93,26 +27,29 @@ module Bindan
|
|
|
93
27
|
# @param [number] timeout
|
|
94
28
|
# @param [number] backoff
|
|
95
29
|
# @param [number] multiplier
|
|
96
|
-
# @return [
|
|
30
|
+
# @return [self]
|
|
97
31
|
#
|
|
98
32
|
def wait_available(timeout: WAIT_TIMEOUT, backoff: INITIAL_BACKOFF_KEY_SEC, multiplier: BACKOFF_MULTIPLIER)
|
|
99
|
-
last_rescued_error = nil
|
|
100
|
-
|
|
101
|
-
count = 1
|
|
102
33
|
Timeout.timeout(timeout) do
|
|
103
34
|
loop do
|
|
104
|
-
if running?
|
|
105
|
-
return
|
|
106
|
-
end
|
|
107
|
-
rescue ContainerCannotOpenError => e
|
|
108
|
-
last_rescued_error = e
|
|
35
|
+
return self if running?
|
|
109
36
|
sleep backoff
|
|
110
|
-
|
|
111
|
-
backoff *= multiplier**count
|
|
37
|
+
backoff *= multiplier
|
|
112
38
|
end
|
|
113
39
|
end
|
|
114
40
|
rescue Timeout::Error
|
|
115
|
-
raise
|
|
41
|
+
raise ContainerCannotOpenError, "Storage readiness timed out"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Query the published loopback endpoint without environment proxies.
|
|
47
|
+
# @return [Net::HTTPResponse] completed response; connection errors propagate
|
|
48
|
+
def read_status
|
|
49
|
+
http = Net::HTTP.new("127.0.0.1", 4443, nil)
|
|
50
|
+
http.open_timeout = 2
|
|
51
|
+
http.read_timeout = 2
|
|
52
|
+
http.get("/storage/v1/b")
|
|
116
53
|
end
|
|
117
54
|
end
|
|
118
55
|
end
|
|
@@ -33,7 +33,20 @@ module Bindan
|
|
|
33
33
|
class ColOrDocNotExist < Error; end
|
|
34
34
|
|
|
35
35
|
#
|
|
36
|
-
#
|
|
36
|
+
# 1. Supply collection when registering the provider to avoid repeating it
|
|
37
|
+
# on each document lookup.
|
|
38
|
+
# 2. Omit collection to access multiple collections through one provider.
|
|
39
|
+
# Each lookup must then explicitly supply a full document path
|
|
40
|
+
# (collection/document).
|
|
41
|
+
#
|
|
42
|
+
# Path examples (collection, key -> document path):
|
|
43
|
+
# - "app-settings", "flags" -> "app-settings/flags"
|
|
44
|
+
# - omitted, "app-settings/flags" -> "app-settings/flags"
|
|
45
|
+
# - omitted, "maintenance/status" -> "maintenance/status"
|
|
46
|
+
# - "app-settings", "other/flags" -> "app-settings/other/flags" (invalid)
|
|
47
|
+
# - omitted, "flags" -> "/flags" (invalid)
|
|
48
|
+
#
|
|
49
|
+
# @param [String, nil] collection fixed collection path, or nil for full-path keys
|
|
37
50
|
# @param [bool] raise_error
|
|
38
51
|
# @param [Google::Cloud::Firestore] sdk
|
|
39
52
|
#
|
|
@@ -61,19 +74,25 @@ module Bindan
|
|
|
61
74
|
@_firestore.doc(path).create(doc)
|
|
62
75
|
end
|
|
63
76
|
|
|
77
|
+
#
|
|
78
|
+
# @param [String] key document ID/path relative to collection, or full path if omitted
|
|
79
|
+
# @return [String]
|
|
80
|
+
#
|
|
81
|
+
def document_path(key)
|
|
82
|
+
if @collection.nil? && key.include?("/")
|
|
83
|
+
key
|
|
84
|
+
else
|
|
85
|
+
[@collection, key].join("/")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
64
89
|
#
|
|
65
90
|
# @raise ColOrDocNotExist
|
|
66
|
-
# @param [String] key
|
|
91
|
+
# @param [String] key document ID/path relative to collection, or full path if omitted
|
|
67
92
|
# @return [Hash]
|
|
68
93
|
#
|
|
69
94
|
def [](key)
|
|
70
|
-
doc_path =
|
|
71
|
-
if @collection.nil? && key.include?("/")
|
|
72
|
-
key
|
|
73
|
-
else
|
|
74
|
-
[@collection, key].join("/")
|
|
75
|
-
end
|
|
76
|
-
|
|
95
|
+
doc_path = document_path(key)
|
|
77
96
|
doc = @_firestore.doc(doc_path).get
|
|
78
97
|
|
|
79
98
|
if @_options[:raise_error] && !doc.data
|
|
@@ -31,9 +31,24 @@ module Bindan
|
|
|
31
31
|
class FileNotExist < Error; end
|
|
32
32
|
|
|
33
33
|
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
34
|
+
# Build the bucket name as [project_id, bucket].join(separator). Keeping
|
|
35
|
+
# the same bucket suffix across projects makes staging environments easier
|
|
36
|
+
# to set up: changing project_id selects the corresponding environment's
|
|
37
|
+
# bucket without changing the suffix or the configuration lookup code.
|
|
38
|
+
#
|
|
39
|
+
# Naming examples (bucket argument: "config-bucket"):
|
|
40
|
+
# - project_id: "my-project", default separator -> "my-project-config-bucket"
|
|
41
|
+
# - project_id: "staging", default separator -> "staging-config-bucket"
|
|
42
|
+
# - project_id omitted, default separator -> "-config-bucket"
|
|
43
|
+
# - project_id omitted, separator: "" -> "config-bucket"
|
|
44
|
+
# - project_id: "my-project", separator: "" -> "my-projectconfig-bucket"
|
|
45
|
+
#
|
|
46
|
+
# Only an explicitly supplied project_id is used in the bucket name;
|
|
47
|
+
# a project ID automatically resolved by the SDK is not used.
|
|
48
|
+
#
|
|
49
|
+
# @param [String] bucket bucket name suffix, not the full bucket name
|
|
50
|
+
# @param [String] project_id project ID used for both the SDK and name prefix
|
|
51
|
+
# @param [String] separator delimiter between project_id and bucket (default: "-")
|
|
37
52
|
# @param [String] credentials
|
|
38
53
|
# @param [Google::Cloud::Storage] sdk
|
|
39
54
|
#
|
data/lib/bindan/version.rb
CHANGED
|
@@ -1,76 +1,47 @@
|
|
|
1
1
|
module Bindan
|
|
2
2
|
module Emulator
|
|
3
3
|
#
|
|
4
|
-
# Represents and controls a single instance of the Google Cloud Firestore emulator
|
|
5
|
-
# This class
|
|
6
|
-
# separating process management from the test execution flow.
|
|
4
|
+
# Represents and controls a single instance of the Google Cloud Firestore emulator service.
|
|
5
|
+
# This class checks readiness and clears test data without managing containers.
|
|
7
6
|
#
|
|
8
7
|
class FirestoreController
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@host: untyped
|
|
12
|
-
|
|
13
|
-
@port: untyped
|
|
14
|
-
|
|
15
|
-
WAIT_TIMEOUT: 15
|
|
8
|
+
WAIT_TIMEOUT: 60
|
|
16
9
|
|
|
17
10
|
INITIAL_BACKOFF_KEY_SEC: ::Float
|
|
18
11
|
|
|
19
12
|
BACKOFF_MULTIPLIER: ::Float
|
|
20
13
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
#
|
|
24
|
-
def self.host_and_port: (?untyped? host, ?untyped? port) -> ::Array[untyped]
|
|
25
|
-
|
|
26
|
-
#
|
|
27
|
-
# Initiates the emulator process and returns a new instance.
|
|
28
|
-
#
|
|
29
|
-
# @param [String] import
|
|
30
|
-
# @param [String] export
|
|
31
|
-
# @param [String] host
|
|
32
|
-
# @param [Integer] port
|
|
33
|
-
# @param [bool] close_io
|
|
34
|
-
# @raise [Errno]
|
|
35
|
-
# @return [FirestoreEmulatorController] An instance to manage the emulator lifecycle.
|
|
36
|
-
#
|
|
37
|
-
def self.start: (?import: untyped?, ?export: untyped?, ?host: untyped?, ?port: untyped?, ?close_io: bool) -> untyped
|
|
14
|
+
class Error < StandardError
|
|
15
|
+
end
|
|
38
16
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
#
|
|
42
|
-
# @param [Integer] pid
|
|
43
|
-
# @param [bool] with_message
|
|
44
|
-
# @return [void]
|
|
45
|
-
#
|
|
46
|
-
def self.stop: (untyped pid, ?with_message: bool) -> untyped
|
|
17
|
+
@project_id: String
|
|
18
|
+
@readiness_error: StandardError?
|
|
47
19
|
|
|
48
|
-
|
|
49
|
-
# @param [Integer] host
|
|
50
|
-
# @param [Integer] port
|
|
51
|
-
#
|
|
52
|
-
def self.kill_process_if_already_exists: (host: untyped, port: untyped, ?with_message: bool) -> untyped
|
|
20
|
+
attr_reader project_id: String
|
|
53
21
|
|
|
54
22
|
#
|
|
55
|
-
#
|
|
23
|
+
# Select the project used by the integration tests.
|
|
56
24
|
#
|
|
57
|
-
def initialize: (
|
|
25
|
+
def initialize: (?project_id: String) -> void
|
|
58
26
|
|
|
59
27
|
#
|
|
60
|
-
#
|
|
28
|
+
# Wait for an SDK read, retrying false with bounded exponential backoff.
|
|
61
29
|
#
|
|
62
|
-
# @raise [
|
|
30
|
+
# @raise [Error] when the overall deadline expires, including during a check
|
|
63
31
|
# @param [number] timeout
|
|
64
32
|
# @param [number] backoff
|
|
65
33
|
# @param [number] multiplier
|
|
66
|
-
# @return [
|
|
34
|
+
# @return [self] after a successful SDK read
|
|
67
35
|
#
|
|
68
36
|
def wait_available: (?timeout: untyped, ?backoff: untyped, ?multiplier: untyped, ?with_message: bool) -> untyped
|
|
69
37
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
38
|
+
def endpoint: () -> String
|
|
39
|
+
def clear: (?timeout: Numeric) -> FirestoreController
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def sdk_ready?: (Numeric timeout) -> bool
|
|
44
|
+
def delete_documents: (String path, Numeric timeout) -> Net::HTTPResponse
|
|
74
45
|
end
|
|
75
46
|
end
|
|
76
47
|
end
|
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
module Bindan
|
|
2
2
|
module Emulator
|
|
3
3
|
class GcsServerController
|
|
4
|
-
@container_name: untyped
|
|
5
|
-
|
|
6
|
-
@folder: untyped
|
|
7
|
-
|
|
8
4
|
class ContainerCannotOpenError < Error
|
|
9
5
|
end
|
|
10
6
|
|
|
11
|
-
CONTAINER_NAME: "gcs-server"
|
|
12
|
-
|
|
13
7
|
WAIT_TIMEOUT: 15
|
|
14
8
|
|
|
15
9
|
INITIAL_BACKOFF_KEY_SEC: ::Float
|
|
@@ -17,55 +11,24 @@ module Bindan
|
|
|
17
11
|
BACKOFF_MULTIPLIER: ::Float
|
|
18
12
|
|
|
19
13
|
#
|
|
20
|
-
# @raise [Errno]
|
|
21
|
-
# @param [String] folder
|
|
22
|
-
# @param [Integer] [port]
|
|
23
|
-
# @param [String] [name]
|
|
24
|
-
# @return [GcsServerController]
|
|
25
|
-
#
|
|
26
|
-
def self.start: (folder: untyped, ?port: ::Integer, ?name: untyped, ?close_io: bool) -> untyped
|
|
27
|
-
|
|
28
|
-
#
|
|
29
|
-
# @raise [IOError]
|
|
30
|
-
# @param [String] name
|
|
31
14
|
# @return [bool]
|
|
32
15
|
#
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
#
|
|
36
|
-
# @param [String] name
|
|
37
|
-
# @raise [Errno]
|
|
38
|
-
# @return [void]
|
|
39
|
-
#
|
|
40
|
-
def self.stop: (name: untyped) -> untyped
|
|
41
|
-
|
|
42
|
-
#
|
|
43
|
-
# @param [String] name
|
|
44
|
-
# @param [String] folder
|
|
45
|
-
#
|
|
46
|
-
def initialize: (name: untyped, folder: untyped) -> void
|
|
47
|
-
|
|
48
|
-
#
|
|
49
|
-
# @return
|
|
50
|
-
#
|
|
51
|
-
def stop: () -> untyped
|
|
52
|
-
|
|
53
|
-
#
|
|
54
|
-
# @param [String] name
|
|
55
|
-
# @return [bool]
|
|
56
|
-
#
|
|
57
|
-
def running?: () -> untyped
|
|
16
|
+
def running?: () -> bool
|
|
58
17
|
|
|
59
18
|
#
|
|
60
19
|
# waits container to be running
|
|
61
20
|
#
|
|
62
|
-
# @raise [
|
|
21
|
+
# @raise [ContainerCannotOpenError] if the container does not become available within the configured timeout.
|
|
63
22
|
# @param [number] timeout
|
|
64
23
|
# @param [number] backoff
|
|
65
24
|
# @param [number] multiplier
|
|
66
|
-
# @return [
|
|
25
|
+
# @return [self]
|
|
67
26
|
#
|
|
68
|
-
def wait_available: (?timeout:
|
|
27
|
+
def wait_available: (?timeout: Numeric, ?backoff: Numeric, ?multiplier: Numeric) -> GcsServerController
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def read_status: () -> Net::HTTPResponse
|
|
69
32
|
end
|
|
70
33
|
end
|
|
71
34
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bindan
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- wtnabe
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-11-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Bindan is a Ruby gem for building single configuration object from various
|
|
14
14
|
sources with providers (that is bundled Google Cloud Storage and Firestore platform
|