@smbcloud / cli / commits / e67f9de

wip

paydii committed Apr 1, 2026 at 20:20 UTC e67f9dedecf7c00c795a11c8e9ccff6df3bb8644
6 files changed +721 -21
.agents/skills/smbcloud-gresiq/SKILL.md new
+148
@@ -0,0 +1,148 @@
1 +---
2 +name: smbcloud-gresiq
3 +description: Use when building, debugging, or extending GresIQ, smbCloud's managed PostgreSQL service, especially the Rust crate in `crates/smbcloud-gresiq`, managed connection bootstrap, branch-aware database config, TLS-aware `tokio-postgres` setup, environment-variable loading, and future Neon or Prisma-style service capabilities such as preview branches, pooled connections, and ephemeral credentials.
4 +---
5 +
6 +# smbCloud GresIQ
7 +
8 +Use this skill when the task is about GresIQ, smbCloud's managed PostgreSQL service.
9 +
10 +Applies to:
11 +
12 +- `crates/smbcloud-gresiq/Cargo.toml`
13 +- `crates/smbcloud-gresiq/src/gresiq.rs`
14 +- `crates/smbcloud-gresiq/src/main.rs`
15 +- `crates/smbcloud-gresiq/README.md`
16 +- workspace wiring for new GresIQ dependencies
17 +- managed Postgres bootstrap design, connection policy, and product-shape changes
18 +
19 +## Scope boundary
20 +
21 +This skill is for the managed Postgres service layer and its Rust bootstrap crate.
22 +
23 +Do not use it for:
24 +
25 +- smbCloud auth flows unless the database service change directly affects auth integration
26 +- generic deploy logic
27 +- unrelated CLI packaging or release automation
28 +
29 +Use the relevant smbCloud auth, deploy, or release skills when those are the actual center of gravity.
30 +
31 +## Source of truth
32 +
33 +Start from these files:
34 +
35 +- `crates/smbcloud-gresiq/src/gresiq.rs`
36 +- `crates/smbcloud-gresiq/src/main.rs`
37 +- `crates/smbcloud-gresiq/Cargo.toml`
38 +- `crates/smbcloud-gresiq/README.md`
39 +- workspace `Cargo.toml` when dependency or member wiring changes
40 +
41 +Treat the Rust crate as the source of truth. Keep future CLI, wasm, Tauri, npm, or native wrappers thin.
42 +
43 +## Current service model
44 +
45 +The current GresIQ bootstrap models:
46 +
47 +- smbCloud environments: `development`, `preview`, `production`
48 +- branch-aware database targets
49 +- endpoint host, port, and SSL mode
50 +- credentials and database name
51 +- connection policy such as compute tier, application name, and connect timeout
52 +- redacted connection-string output
53 +- a connectivity health check using `tokio-postgres`
54 +
55 +Preserve these properties unless the task explicitly changes the product model.
56 +
57 +## Product direction
58 +
59 +Take inspiration from Neon, Prisma Postgres, and similar managed database products, but fit the smbCloud platform rather than copying their APIs blindly.
60 +
61 +Good directions:
62 +
63 +- preview branches per app environment
64 +- autoscaling or burst compute defaults
65 +- pooled or proxy-backed connection paths for serverless runtimes
66 +- safe credential handling and redacted logs
67 +- ephemeral or tenant-scoped credentials
68 +- operational checks that surface actionable failures
69 +
70 +Avoid adding speculative product features with no clear interface or storage model.
71 +
72 +## Implementation guidance
73 +
74 +### Crate shape
75 +
76 +Prefer a library-first design:
77 +
78 +- reusable Rust API in `src/gresiq.rs`
79 +- thin binary in `src/main.rs`
80 +
81 +If additional surfaces are added later, bind to the existing Rust API rather than duplicating logic.
82 +
83 +### Configuration
84 +
85 +Make environment loading explicit and typed.
86 +
87 +Expected config areas:
88 +
89 +- smbCloud environment
90 +- project slug
91 +- branch name
92 +- region
93 +- host and port
94 +- SSL mode
95 +- username, password, and database name
96 +- compute or connection policy
97 +
98 +Do not hardcode localhost defaults except in intentionally local examples.
99 +
100 +### Connections
101 +
102 +Prefer correctness over cleverness.
103 +
104 +- use TLS when the endpoint requires it
105 +- keep connection-string generation available for integration points
106 +- provide a redacted form for logs and CLI output
107 +- propagate errors instead of hiding them behind generic failures
108 +
109 +If adding pooling, document whether the crate owns pooling or whether it only models direct connections.
110 +
111 +### Errors
112 +
113 +Use typed error enums for:
114 +
115 +- invalid configuration
116 +- invalid environment variable values
117 +- TLS setup failures
118 +- PostgreSQL connection or query failures
119 +
120 +Do not use `unwrap()` in the service path.
121 +
122 +## Validation
123 +
124 +Use the smallest relevant checks first.
125 +
126 +### Rust
127 +
128 +- `cargo fmt --package gresiq`
129 +- `cargo check -p gresiq`
130 +- `cargo test -p gresiq`
131 +
132 +### Runtime
133 +
134 +If the task changes live connection behavior, validate with a real managed endpoint when credentials are available:
135 +
136 +- set `GRESIQ_*` environment variables
137 +- run `cargo run -p gresiq`
138 +
139 +Do not claim runtime validation happened unless a real endpoint was exercised.
140 +
141 +## Common mistakes
142 +
143 +- treating GresIQ like a one-off example binary instead of a reusable service crate
144 +- logging raw passwords or full unredacted connection strings
145 +- hardcoding insecure defaults for production-facing paths
146 +- mixing up smbCloud environment semantics with database branch semantics
147 +- adding platform wrappers that duplicate the Rust logic
148 +- introducing pooling, proxy, or branch-management claims in the README without implementing the underlying API
Cargo.lock
+16
@@ -666,6 +666,10 @@ dependencies = [
666 name = "gresiq"
667 version = "0.3.26"
668 dependencies = [
669 + "native-tls",
670 + "postgres-native-tls",
671 + "serde",
672 + "thiserror 2.0.18",
673 "tokio",
674 "tokio-postgres",
675 ]
@@ -1462,6 +1466,18 @@ version = "1.13.1"
1466 source = "registry+https://github.com/rust-lang/crates.io-index"
1467 checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
1468
1469 +[[package]]
1470 +name = "postgres-native-tls"
1471 +version = "0.5.3"
1472 +source = "registry+https://github.com/rust-lang/crates.io-index"
1473 +checksum = "fef4de47bb81477e0c3deaf153a1b10ae176484713ff1640969f4cb96b653ebc"
1474 +dependencies = [
1475 + "native-tls",
1476 + "tokio",
1477 + "tokio-native-tls",
1478 + "tokio-postgres",
1479 +]
1480 +
1481 [[package]]
1482 name = "postgres-protocol"
1483 version = "0.6.10"
crates/smbcloud-gresiq/Cargo.toml
+7
@@ -17,6 +17,13 @@ categories = [
17 "wasm",
18 ]
19
20 +[lib]
21 +path = "src/gresiq.rs"
22 +
23 [dependencies]
24 +native-tls = "0.2"
25 +postgres-native-tls = "0.5"
26 +serde = { workspace = true, features = ["derive"] }
27 +thiserror = { workspace = true }
28 tokio = { workspace = true, features = ["full"] }
29 tokio-postgres = { workspace = true }
crates/smbcloud-gresiq/README.md
+81 -2
@@ -1,3 +1,82 @@
1 -# smbCloud GresIQ
1 +# GresIQ
2
3 -GresIQ is a serverless PostgreSQL client for the smbCloud platform.
3 +GresIQ is smbCloud's managed PostgreSQL bootstrap crate for small and medium businesses. It is designed around the product shape teams expect from Neon, Prisma Postgres, and similar services:
4 +
5 +- branch-aware environments
6 +- autoscaling-friendly connection defaults
7 +- safe connection-string handling
8 +- a small Rust API that can sit underneath CLI, desktop, web, or future bindings
9 +
10 +This crate is the Rust source of truth for bootstrapping a GresIQ connection and validating that a managed database endpoint is reachable.
11 +
12 +## What the bootstrap includes
13 +
14 +- `GresiqConfig` for loading a managed Postgres target from environment variables
15 +- explicit smbCloud environment handling for `development`, `preview`, and `production`
16 +- branch metadata for project, branch, and region aware workloads
17 +- TLS-aware `tokio-postgres` connection setup
18 +- redacted connection-string output for logs and CLI diagnostics
19 +- a health-check query that returns database name, user, and server version
20 +
21 +## Environment variables
22 +
23 +`GresiqConfig::from_env()` reads:
24 +
25 +```text
26 +GRESIQ_PROJECT_SLUG
27 +GRESIQ_HOST
28 +GRESIQ_USERNAME
29 +GRESIQ_PASSWORD
30 +GRESIQ_BRANCH=main
31 +GRESIQ_REGION=eu-north-1
32 +GRESIQ_DATABASE=postgres
33 +GRESIQ_PORT=5432
34 +GRESIQ_SSL_MODE=require
35 +GRESIQ_COMPUTE_TIER=autoscale
36 +GRESIQ_APPLICATION_NAME=gresiq
37 +GRESIQ_CONNECT_TIMEOUT_SECONDS=10
38 +SMBCLOUD_ENVIRONMENT=development
39 +```
40 +
41 +## Example
42 +
43 +```rust
44 +use gresiq::GresiqConfig;
45 +
46 +#[tokio::main]
47 +async fn main() -> Result<(), Box<dyn std::error::Error>> {
48 + let config = GresiqConfig::from_env()?;
49 + let connection = config.connect().await?;
50 + let health_check = connection.health_check().await?;
51 +
52 + println!("{}", config.redacted_connection_string());
53 + println!("{}", health_check.server_version);
54 +
55 + Ok(())
56 +}
57 +```
58 +
59 +## CLI bootstrap
60 +
61 +Run the crate binary directly to validate a managed endpoint:
62 +
63 +```bash
64 +cargo run -p gresiq
65 +```
66 +
67 +The binary prints:
68 +
69 +- branch and region summary
70 +- selected compute tier
71 +- redacted endpoint URL
72 +- database health-check details
73 +
74 +## Product direction
75 +
76 +The current bootstrap focuses on connection modeling and operational correctness. The next logical layer for GresIQ is:
77 +
78 +- branch lifecycle APIs for preview environments
79 +- pooled or proxy-based serverless connection routing
80 +- passwordless or ephemeral credentials
81 +- metrics, query insights, and tenant-aware usage controls
82 +- adapters for smbCloud CLI, Tauri, wasm, npm, and native SDKs
crates/smbcloud-gresiq/src/gresiq.rs new
+457
@@ -0,0 +1,457 @@
1 +use native_tls::TlsConnector;
2 +use postgres_native_tls::MakeTlsConnector;
3 +use serde::{Deserialize, Serialize};
4 +use std::{env, fmt, num::ParseIntError};
5 +use thiserror::Error;
6 +use tokio::task::JoinHandle;
7 +use tokio_postgres::{
8 + Client, Config as PostgresConfig, Error as PostgresError, NoTls, Row,
9 + config::SslMode as PostgresSslMode,
10 +};
11 +
12 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13 +pub enum GresiqEnvironment {
14 + Development,
15 + Preview,
16 + Production,
17 +}
18 +
19 +impl GresiqEnvironment {
20 + pub fn as_str(self) -> &'static str {
21 + match self {
22 + Self::Development => "development",
23 + Self::Preview => "preview",
24 + Self::Production => "production",
25 + }
26 + }
27 +}
28 +
29 +impl fmt::Display for GresiqEnvironment {
30 + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31 + formatter.write_str(self.as_str())
32 + }
33 +}
34 +
35 +impl std::str::FromStr for GresiqEnvironment {
36 + type Err = GresiqConfigError;
37 +
38 + fn from_str(value: &str) -> Result<Self, Self::Err> {
39 + match value.trim().to_ascii_lowercase().as_str() {
40 + "development" | "dev" => Ok(Self::Development),
41 + "preview" | "staging" => Ok(Self::Preview),
42 + "production" | "prod" => Ok(Self::Production),
43 + _ => Err(GresiqConfigError::InvalidEnvironment(value.to_owned())),
44 + }
45 + }
46 +}
47 +
48 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49 +pub enum ComputeTier {
50 + Burst,
51 + Autoscale,
52 + Dedicated,
53 +}
54 +
55 +impl ComputeTier {
56 + pub fn as_str(self) -> &'static str {
57 + match self {
58 + Self::Burst => "burst",
59 + Self::Autoscale => "autoscale",
60 + Self::Dedicated => "dedicated",
61 + }
62 + }
63 +}
64 +
65 +impl fmt::Display for ComputeTier {
66 + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67 + formatter.write_str(self.as_str())
68 + }
69 +}
70 +
71 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72 +pub struct BranchTarget {
73 + pub project_slug: String,
74 + pub branch_name: String,
75 + pub region: String,
76 + pub environment: GresiqEnvironment,
77 +}
78 +
79 +impl BranchTarget {
80 + pub fn summary(&self) -> String {
81 + format!(
82 + "{}/{}/{} ({})",
83 + self.project_slug, self.branch_name, self.region, self.environment
84 + )
85 + }
86 +}
87 +
88 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89 +pub struct Credentials {
90 + pub username: String,
91 + pub password: String,
92 + pub database_name: String,
93 +}
94 +
95 +impl Credentials {
96 + pub fn redacted_password(&self) -> &'static str {
97 + "********"
98 + }
99 +}
100 +
101 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102 +pub struct Endpoint {
103 + pub host: String,
104 + pub port: u16,
105 + pub ssl_mode: SslMode,
106 +}
107 +
108 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109 +pub enum SslMode {
110 + Disable,
111 + Prefer,
112 + Require,
113 +}
114 +
115 +impl SslMode {
116 + pub fn as_str(self) -> &'static str {
117 + match self {
118 + Self::Disable => "disable",
119 + Self::Prefer => "prefer",
120 + Self::Require => "require",
121 + }
122 + }
123 +}
124 +
125 +impl fmt::Display for SslMode {
126 + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
127 + formatter.write_str(self.as_str())
128 + }
129 +}
130 +
131 +impl std::str::FromStr for SslMode {
132 + type Err = GresiqConfigError;
133 +
134 + fn from_str(value: &str) -> Result<Self, Self::Err> {
135 + match value.trim().to_ascii_lowercase().as_str() {
136 + "disable" => Ok(Self::Disable),
137 + "prefer" => Ok(Self::Prefer),
138 + "require" => Ok(Self::Require),
139 + _ => Err(GresiqConfigError::InvalidSslMode(value.to_owned())),
140 + }
141 + }
142 +}
143 +
144 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145 +pub struct ConnectionPolicy {
146 + pub compute_tier: ComputeTier,
147 + pub application_name: String,
148 + pub connect_timeout_seconds: u64,
149 +}
150 +
151 +impl Default for ConnectionPolicy {
152 + fn default() -> Self {
153 + Self {
154 + compute_tier: ComputeTier::Autoscale,
155 + application_name: "gresiq".to_owned(),
156 + connect_timeout_seconds: 10,
157 + }
158 + }
159 +}
160 +
161 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162 +pub struct GresiqConfig {
163 + pub branch_target: BranchTarget,
164 + pub endpoint: Endpoint,
165 + pub credentials: Credentials,
166 + pub connection_policy: ConnectionPolicy,
167 +}
168 +
169 +impl GresiqConfig {
170 + pub fn from_env() -> Result<Self, GresiqConfigError> {
171 + let environment = env::var("SMBCLOUD_ENVIRONMENT")
172 + .unwrap_or_else(|_| "development".to_owned())
173 + .parse()?;
174 +
175 + let ssl_mode = env::var("GRESIQ_SSL_MODE")
176 + .unwrap_or_else(|_| "require".to_owned())
177 + .parse()?;
178 +
179 + let port = read_optional_port("GRESIQ_PORT")?.unwrap_or(5432);
180 +
181 + let connect_timeout_seconds =
182 + read_optional_u64("GRESIQ_CONNECT_TIMEOUT_SECONDS")?.unwrap_or(10);
183 +
184 + let compute_tier = match env::var("GRESIQ_COMPUTE_TIER")
185 + .unwrap_or_else(|_| "autoscale".to_owned())
186 + .trim()
187 + .to_ascii_lowercase()
188 + .as_str()
189 + {
190 + "burst" => ComputeTier::Burst,
191 + "autoscale" => ComputeTier::Autoscale,
192 + "dedicated" => ComputeTier::Dedicated,
193 + other => {
194 + return Err(GresiqConfigError::InvalidComputeTier(other.to_owned()));
195 + }
196 + };
197 +
198 + Ok(Self {
199 + branch_target: BranchTarget {
200 + project_slug: read_required_env("GRESIQ_PROJECT_SLUG")?,
201 + branch_name: env::var("GRESIQ_BRANCH").unwrap_or_else(|_| "main".to_owned()),
202 + region: env::var("GRESIQ_REGION").unwrap_or_else(|_| "eu-north-1".to_owned()),
203 + environment,
204 + },
205 + endpoint: Endpoint {
206 + host: read_required_env("GRESIQ_HOST")?,
207 + port,
208 + ssl_mode,
209 + },
210 + credentials: Credentials {
211 + username: read_required_env("GRESIQ_USERNAME")?,
212 + password: read_required_env("GRESIQ_PASSWORD")?,
213 + database_name: env::var("GRESIQ_DATABASE")
214 + .unwrap_or_else(|_| "postgres".to_owned()),
215 + },
216 + connection_policy: ConnectionPolicy {
217 + compute_tier,
218 + application_name: env::var("GRESIQ_APPLICATION_NAME")
219 + .unwrap_or_else(|_| "gresiq".to_owned()),
220 + connect_timeout_seconds,
221 + },
222 + })
223 + }
224 +
225 + pub fn connection_string(&self) -> String {
226 + format!(
227 + "postgresql://{}:{}@{}:{}/{}?sslmode={}&application_name={}",
228 + self.credentials.username,
229 + self.credentials.password,
230 + self.endpoint.host,
231 + self.endpoint.port,
232 + self.credentials.database_name,
233 + self.endpoint.ssl_mode,
234 + self.connection_policy.application_name
235 + )
236 + }
237 +
238 + pub fn redacted_connection_string(&self) -> String {
239 + format!(
240 + "postgresql://{}:{}@{}:{}/{}?sslmode={}&application_name={}",
241 + self.credentials.username,
242 + self.credentials.redacted_password(),
243 + self.endpoint.host,
244 + self.endpoint.port,
245 + self.credentials.database_name,
246 + self.endpoint.ssl_mode,
247 + self.connection_policy.application_name
248 + )
249 + }
250 +
251 + pub fn postgres_config(&self) -> PostgresConfig {
252 + let mut config = PostgresConfig::new();
253 + config.host(&self.endpoint.host);
254 + config.port(self.endpoint.port);
255 + config.user(&self.credentials.username);
256 + config.password(&self.credentials.password);
257 + config.dbname(&self.credentials.database_name);
258 + config.application_name(&self.connection_policy.application_name);
259 + config.connect_timeout(std::time::Duration::from_secs(
260 + self.connection_policy.connect_timeout_seconds,
261 + ));
262 + config.ssl_mode(match self.endpoint.ssl_mode {
263 + SslMode::Disable => PostgresSslMode::Disable,
264 + SslMode::Prefer => PostgresSslMode::Prefer,
265 + SslMode::Require => PostgresSslMode::Require,
266 + });
267 + config
268 + }
269 +
270 + pub async fn connect(&self) -> Result<GresiqConnection, GresiqConnectError> {
271 + let postgres_config = self.postgres_config();
272 +
273 + match self.endpoint.ssl_mode {
274 + SslMode::Disable => {
275 + let (client, connection) = postgres_config.connect(NoTls).await?;
276 + let connection_task = tokio::spawn(async move { connection.await });
277 +
278 + Ok(GresiqConnection {
279 + client,
280 + connection_task: Some(connection_task),
281 + })
282 + }
283 + SslMode::Prefer | SslMode::Require => {
284 + let tls_connector = TlsConnector::builder().build()?;
285 + let tls_connector = MakeTlsConnector::new(tls_connector);
286 + let (client, connection) = postgres_config.connect(tls_connector).await?;
287 + let connection_task = tokio::spawn(async move { connection.await });
288 +
289 + Ok(GresiqConnection {
290 + client,
291 + connection_task: Some(connection_task),
292 + })
293 + }
294 + }
295 + }
296 +}
297 +
298 +#[derive(Debug)]
299 +pub struct GresiqConnection {
300 + client: Client,
301 + connection_task: Option<JoinHandle<Result<(), PostgresError>>>,
302 +}
303 +
304 +impl GresiqConnection {
305 + pub fn client(&self) -> &Client {
306 + &self.client
307 + }
308 +
309 + pub async fn health_check(&self) -> Result<GresiqHealthCheck, GresiqConnectError> {
310 + let row = self
311 + .client
312 + .query_one("select current_database(), current_user, version()", &[])
313 + .await?;
314 +
315 + Ok(GresiqHealthCheck::from_row(&row))
316 + }
317 +
318 + pub async fn close(mut self) -> Result<(), GresiqConnectError> {
319 + if let Some(connection_task) = self.connection_task.take() {
320 + connection_task.await??;
321 + }
322 + Ok(())
323 + }
324 +}
325 +
326 +impl Drop for GresiqConnection {
327 + fn drop(&mut self) {
328 + if let Some(connection_task) = self.connection_task.take() {
329 + connection_task.abort();
330 + }
331 + }
332 +}
333 +
334 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335 +pub struct GresiqHealthCheck {
336 + pub database_name: String,
337 + pub username: String,
338 + pub server_version: String,
339 +}
340 +
341 +impl GresiqHealthCheck {
342 + fn from_row(row: &Row) -> Self {
343 + Self {
344 + database_name: row.get(0),
345 + username: row.get(1),
346 + server_version: row.get(2),
347 + }
348 + }
349 +}
350 +
351 +#[derive(Debug, Error)]
352 +pub enum GresiqConfigError {
353 + #[error("missing required environment variable `{0}`")]
354 + MissingEnvVar(&'static str),
355 + #[error("environment variable `{0}` contains invalid unicode")]
356 + InvalidEnvUnicode(&'static str),
357 + #[error("failed to parse environment variable `{name}` as an integer")]
358 + InvalidNumber {
359 + name: &'static str,
360 + #[source]
361 + source: ParseIntError,
362 + },
363 + #[error("invalid smbCloud environment `{0}`")]
364 + InvalidEnvironment(String),
365 + #[error("invalid GresIQ ssl mode `{0}`")]
366 + InvalidSslMode(String),
367 + #[error("invalid GresIQ compute tier `{0}`")]
368 + InvalidComputeTier(String),
369 +}
370 +
371 +#[derive(Debug, Error)]
372 +pub enum GresiqConnectError {
373 + #[error(transparent)]
374 + Tls(#[from] native_tls::Error),
375 + #[error(transparent)]
376 + Postgres(#[from] PostgresError),
377 + #[error("postgres connection task failed")]
378 + Join(#[from] tokio::task::JoinError),
379 +}
380 +
381 +fn read_required_env(name: &'static str) -> Result<String, GresiqConfigError> {
382 + env::var(name).map_err(|_| GresiqConfigError::MissingEnvVar(name))
383 +}
384 +
385 +fn read_optional_port(name: &'static str) -> Result<Option<u16>, GresiqConfigError> {
386 + read_optional_env(name)?
387 + .map(|value| {
388 + value
389 + .parse()
390 + .map_err(|source| GresiqConfigError::InvalidNumber { name, source })
391 + })
392 + .transpose()
393 +}
394 +
395 +fn read_optional_u64(name: &'static str) -> Result<Option<u64>, GresiqConfigError> {
396 + read_optional_env(name)?
397 + .map(|value| {
398 + value
399 + .parse()
400 + .map_err(|source| GresiqConfigError::InvalidNumber { name, source })
401 + })
402 + .transpose()
403 +}
404 +
405 +fn read_optional_env(name: &'static str) -> Result<Option<String>, GresiqConfigError> {
406 + match env::var(name) {
407 + Ok(value) => Ok(Some(value)),
408 + Err(env::VarError::NotPresent) => Ok(None),
409 + Err(env::VarError::NotUnicode(_)) => Err(GresiqConfigError::InvalidEnvUnicode(name)),
410 + }
411 +}
412 +
413 +#[cfg(test)]
414 +mod tests {
415 + use super::*;
416 +
417 + #[test]
418 + fn redacted_connection_string_hides_the_password() {
419 + let config = GresiqConfig {
420 + branch_target: BranchTarget {
421 + project_slug: "acme-store".to_owned(),
422 + branch_name: "main".to_owned(),
423 + region: "eu-north-1".to_owned(),
424 + environment: GresiqEnvironment::Production,
425 + },
426 + endpoint: Endpoint {
427 + host: "main.acme-store.gresiq.smbcloud.xyz".to_owned(),
428 + port: 5432,
429 + ssl_mode: SslMode::Require,
430 + },
431 + credentials: Credentials {
432 + username: "postgres".to_owned(),
433 + password: "super-secret".to_owned(),
434 + database_name: "app".to_owned(),
435 + },
436 + connection_policy: ConnectionPolicy::default(),
437 + };
438 +
439 + assert!(!config.redacted_connection_string().contains("super-secret"));
440 + assert!(config.redacted_connection_string().contains("********"));
441 + }
442 +
443 + #[test]
444 + fn branch_summary_matches_expected_shape() {
445 + let branch_target = BranchTarget {
446 + project_slug: "acme-store".to_owned(),
447 + branch_name: "preview".to_owned(),
448 + region: "eu-west-1".to_owned(),
449 + environment: GresiqEnvironment::Preview,
450 + };
451 +
452 + assert_eq!(
453 + branch_target.summary(),
454 + "acme-store/preview/eu-west-1 (preview)"
455 + );
456 + }
457 +}
crates/smbcloud-gresiq/src/main.rs
+12 -19
@@ -1,27 +1,20 @@
1 -use tokio_postgres::{Error, NoTls};
1 +use gresiq::GresiqConfig;
2
3 #[tokio::main]
4 -async fn main() -> Result<(), Error> {
5 - println!("Hello, GresIQ client!");
4 +async fn main() -> Result<(), Box<dyn std::error::Error>> {
5 + let config = GresiqConfig::from_env()?;
6
7 - // Connect to the database.
8 - let (client, connection) =
9 - tokio_postgres::connect("host=localhost user=postgres", NoTls).await?;
7 + println!("GresIQ bootstrap");
8 + println!(" branch: {}", config.branch_target.summary());
9 + println!(" compute: {}", config.connection_policy.compute_tier);
10 + println!(" endpoint: {}", config.redacted_connection_string());
11
11 - // The connection object performs the actual communication with the database,
12 - // so spawn it off to run on its own.
13 - tokio::spawn(async move {
14 - if let Err(e) = connection.await {
15 - eprintln!("connection error: {}", e);
16 - }
17 - });
12 + let connection = config.connect().await?;
13 + let health_check = connection.health_check().await?;
14
19 - // Now we can execute a simple statement that just returns its parameter.
20 - let rows = client.query("SELECT $1::TEXT", &[&"hello world"]).await?;
21 -
22 - // And then check that we got back the same string we sent over.
23 - let value: &str = rows[0].get(0);
24 - assert_eq!(value, "hello world");
15 + println!(" database: {}", health_check.database_name);
16 + println!(" user: {}", health_check.username);
17 + println!(" server: {}", health_check.server_version);
18
19 Ok(())
20 }