Skip to content

Commit 7fec8fc

Browse files
recipe(cloud-connect-dev): demonstrate secrets sync against a local Postgres
The recipe gains a step where the portal delivers the password for a local Postgres dataset: the Spicepod names `${secrets:pg_password}`, the value is set once in the project's secrets and arrives with the deployment, and nothing about the database password is written to the repository or to the instance's config in plaintext. It teaches the whole model rather than the happy path — that no `secrets:` section is needed because the delivered store is built in at the lowest precedence, that a local env override still wins, that a first delivery resolves live while a rotation of an already-resolved value waits for a restart, and that the encrypted local cache is what lets that restart succeed with the gateway unreachable.
1 parent 8e130be commit 7fec8fc

5 files changed

Lines changed: 254 additions & 10 deletions

File tree

cloud-connect-dev/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
# Cloud Connect writes the enrolled instance identity and the cloud-managed
22
# Spicepod here. Neither belongs in version control.
33
.spice/
4+
# The demo password lives in the shell, never in a file.
5+
.env
6+
.env.local

cloud-connect-dev/README.md

Lines changed: 153 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@ Works with `v2.2+`
44

55
Connect this directory to Spice Cloud with one command, watch a deployment apply to the running instance without a restart, stop it, and reconnect.
66

7-
The recipe is deliberately minimal: one public dataset, no infrastructure, no credentials on disk. What it demonstrates is the [Cloud Connect](https://spiceai.org/docs/deployment/cloud-connect) lifecycle on the machine you develop on.
7+
The recipe stays small: one public dataset, one local PostgreSQL in Docker, and no credential written to any file it tracks. What it demonstrates is the [Cloud Connect](https://spiceai.org/docs/deployment/cloud-connect) lifecycle on the machine you develop on, including how a deployment delivers the secrets its configuration needs.
88

99
You will:
1010

1111
1. Connect this directory and get a running instance attached to a new Spice Cloud project.
1212
2. Deploy a change that applies live, with no restart and no serving gap.
13-
3. Deploy a change that needs a restart, and see exactly which settings are pending.
14-
4. Stop with `Ctrl-C` and reconnect with `spice run` — the identity survives.
15-
5. Release the instance and delete its project.
13+
3. Deliver a secret with a deployment, and query a local PostgreSQL whose password only ever existed in your shell and in the portal.
14+
4. Deploy a change that needs a restart, and see exactly which settings are pending.
15+
5. Stop with `Ctrl-C` and reconnect with `spice run` — the identity survives.
16+
6. Release the instance and delete its project.
1617

1718
## Prerequisites
1819

@@ -24,10 +25,11 @@ You will:
2425

2526
- A Spice Cloud account with the **owner** or **admin** role in at least one organization. Those roles may enroll an instance and create a project; `member` may not.
2627
- Outbound HTTPS to `api.spice.ai` and to the Cloud Connect gateway.
28+
- Docker, for the local PostgreSQL in step 3. Steps 1, 2 and 4 onwards do not need it.
2729

2830
No `spice login` beforehand — `spice connect` runs login inline if needed.
2931

30-
**Cost:** a Spice Cloud project on the free tier. Step 6 deletes it.
32+
**Cost:** a Spice Cloud project on the free tier. Step 7 deletes it, and the database is a local container.
3133

3234
## 1. Connect
3335

@@ -135,7 +137,125 @@ SELECT count(*) FROM recent;
135137
secrets: none (the last deployment delivered no secrets)
136138
```
137139

138-
## 3. Deploy a change that needs a restart
140+
## 3. Deliver a secret with a deployment
141+
142+
A deployment carries configuration **and** the secrets that configuration needs. This step connects a local PostgreSQL as a dataset whose password is never written in the Spicepod, never committed, and never typed on this machine after the first line below.
143+
144+
Start the database. The password lives in your shell, and the container is the only thing on this machine that receives it:
145+
146+
```bash
147+
export SPICE_DEMO_PG_PASSWORD="$(openssl rand -hex 16)"
148+
docker compose up -d
149+
```
150+
151+
It seeds one table:
152+
153+
```bash
154+
docker compose exec postgres psql -U spice_reader -d spice_demo -c 'SELECT count(*) FROM public.orders;'
155+
```
156+
157+
```console
158+
count
159+
-------
160+
5
161+
(1 row)
162+
```
163+
164+
Now put that same value in the portal. In the project's **Secrets**, add:
165+
166+
| Name | Value |
167+
| ------------- | -------------------------------------- |
168+
| `pg_password` | the value of `$SPICE_DEMO_PG_PASSWORD` |
169+
170+
Then add the dataset to the project's Spicepod and deploy:
171+
172+
```yaml
173+
datasets:
174+
- from: postgres:public.orders
175+
name: orders
176+
params:
177+
pg_host: localhost
178+
pg_port: "55432"
179+
pg_db: spice_demo
180+
pg_user: spice_reader
181+
pg_pass: ${secrets:pg_password}
182+
pg_sslmode: disable # a local container, so there is no TLS to verify
183+
```
184+
185+
The deployment applies live — a dataset is a live section, and a secret this instance did not hold yet is installed the moment it arrives, so the dataset resolves its password as it loads:
186+
187+
```console
188+
INFO Spice Cloud Connect: the deployment delivered 1 secret(s) this instance did not hold, now resolvable: pg_password
189+
INFO runtime::secrets_preflight: Secrets: all 1 secret reference(s) resolved.
190+
INFO runtime::init::dataset: Dataset orders registered (postgres:public.orders), results cache enabled. duration_ms=0
191+
```
192+
193+
The PID is still the one from step 1. Query the table the portal just wired up:
194+
195+
```bash
196+
spice sql
197+
```
198+
199+
```sql
200+
SELECT customer, total_cents FROM orders ORDER BY id LIMIT 3;
201+
```
202+
203+
```console
204+
+----------+-------------+
205+
| customer | total_cents |
206+
| varchar | int32 |
207+
+----------+-------------+
208+
| acme | 12900 |
209+
| globex | 4550 |
210+
| initech | 31875 |
211+
+----------+-------------+
212+
213+
Time: 0.003107 seconds. 3 rows.
214+
```
215+
216+
Status names what was delivered — the name only:
217+
218+
```bash
219+
spice connect status
220+
```
221+
222+
```console
223+
deployment: <path>/cloud-connect-dev/.spice/spicepod-cloud-managed.yml
224+
secrets: 1 delivered: pg_password
225+
```
226+
227+
### Why the Spicepod has no `secrets:` section
228+
229+
`${secrets:pg_password}` resolves without declaring anything, because delivered secrets are a **built-in** store rather than one a Spicepod configures. That is what keeps the same Spicepod portable: it runs unchanged as a managed app and on this self-hosted instance, with no `secrets:` block that would be meaningless on the other.
230+
231+
### A local value still wins
232+
233+
The delivered store is registered at the **lowest** precedence, so any store you configure yourself — `env`, a `.env.local` file, a keyring — keeps its value for the same key. Delivery adds a source; it does not take your override away:
234+
235+
```bash
236+
# The env store maps ${secrets:pg_password} to PG_PASSWORD, uppercased.
237+
export PG_PASSWORD="$SPICE_DEMO_PG_PASSWORD"
238+
```
239+
240+
That is how you keep working against the same Spicepod with no portal round trip. Unset it before continuing, or the delivered value is never the one in use:
241+
242+
```bash
243+
unset PG_PASSWORD
244+
```
245+
246+
### If you deploy the dataset before setting the secret
247+
248+
The runtime says exactly what is missing and which stores it searched, and the dataset is the only thing that fails:
249+
250+
```console
251+
WARN runtime::secrets_preflight: Secrets: 1 of 1 secret reference(s) could not be resolved:
252+
✗ ${ secrets:pg_password } dataset 'orders' (pg_pass) — not found in [env, cloud]
253+
ERROR runtime::init::dataset: Error initializing dataset orders. Failed to initialize data connector: Cannot connect to the dataset orders (postgres). Authentication failed.
254+
```
255+
256+
Set the secret in the portal and deploy again — the instance keeps serving everything else throughout.
257+
258+
## 4. Deploy a change that needs a restart
139259

140260
Some sections are read only when the runtime starts. Add one in the portal — for example a `runtime` setting — and deploy again:
141261

@@ -184,9 +304,24 @@ curl -s http://127.0.0.1:8090/v1/cloud-connect/status | jq
184304
}
185305
```
186306

187-
The PID is still unchanged, and queries still work. In the foreground there is no supervisor to restart the instance — that is step 4.
307+
The PID is still unchanged, and queries still work. In the foreground there is no supervisor to restart the instance — that is step 5.
308+
309+
### A rotated secret is the same shape
310+
311+
The first delivery of `pg_password` in step 3 applied live because nothing had resolved it yet. Rotating it is different: the components holding the old value keep it until they are built again, so a rotation is pending in exactly the way a `runtime` setting is. Change the password in the database, in the portal secret, and deploy:
312+
313+
```bash
314+
docker compose exec postgres psql -U spice_reader -d spice_demo \
315+
-c "ALTER ROLE spice_reader WITH PASSWORD 'the-new-value';"
316+
```
317+
318+
```console
319+
restart: required for secrets
320+
```
321+
322+
The instance goes on serving with the connection it already has. The restart in step 5 is what picks the new value up — and it reads it from the encrypted cache in `.spice/`, so it succeeds even with no route to Spice Cloud.
188323

189-
## 4. Stop and reconnect
324+
## 5. Stop and reconnect
190325

191326
In the foreground terminal, press `Ctrl-C`. The runtime stops. The Cloud identity is **not** released: the instance shows as offline in the portal and `.spice/identity.json` is still on disk.
192327

@@ -219,7 +354,7 @@ The `restart:` line is gone.
219354

220355
`spice connect` from this directory does the same thing. It never enrolls a second instance — an existing identity always wins.
221356

222-
## 5. Validate locally
357+
## 6. Validate locally
223358

224359
`validate.sh` checks the parts of this recipe that need no Spice Cloud account and no credentials. Run it any time:
225360

@@ -229,7 +364,7 @@ The `restart:` line is gone.
229364

230365
It asserts that the CLI is new enough, that the project-name default derives from this directory with no random fallback, that `spice connect status` reports a coherent snapshot in both output formats, that a non-interactive `spice connect` refuses instead of hanging, and that no credential or generated identity is staged for commit.
231366

232-
## 6. Clean up
367+
## 7. Clean up
233368

234369
Stop the runtime first — removal refuses while `spiced` is running — then release the instance:
235370

@@ -256,9 +391,17 @@ spice connect status
256391
Spice Cloud Connect: not connected (<path>/cloud-connect-dev)
257392
```
258393

394+
Then stop the database and drop its volume:
395+
396+
```bash
397+
docker compose down -v
398+
unset SPICE_DEMO_PG_PASSWORD
399+
```
400+
259401
## Notes
260402

261403
- **Nothing is committed.** `.spice/` is gitignored: the issued identity, the cloud-managed Spicepod, and the delivered-secrets cache all live there. `validate.sh` checks it.
404+
- **A delivered value is never in plaintext on disk.** The cache under `.spice/` is sealed with a key derived from this instance's identity, and it is what makes a restart work without asking the control plane for the secret again. `spice connect status` reports delivered secret **names**; the values stay inside the runtime process.
262405
- **`spice connect` needs a terminal.** It is interactive; on a non-interactive stdin it exits and points at `spiced --token <enrollment-key>` for [headless enrollment](https://spiceai.org/docs/deployment/cloud-connect/headless).
263406
- **Cancelling is safe.** `Esc` or `Ctrl-C` at any prompt is a clean exit. An interrupted enrollment resumes on the next run rather than creating a duplicate instance or project.
264407
- **This recipe is foreground only.** To keep the instance running across reboots, install the managed service — systemd on Linux, launchd on macOS. See [Cloud Connect as a persistent service](https://spiceai.org/docs/deployment/cloud-connect/service). Windows has no managed service; there, the foreground flow in this recipe is the development story.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# A local PostgreSQL for the secrets step of the README.
2+
#
3+
# The password is never written here. It is read from the environment, and the
4+
# same value is what you set as the `pg_password` secret in the Spice Cloud
5+
# portal — so the one credential this recipe uses exists in your shell and in
6+
# the portal, and in no file this repository tracks.
7+
services:
8+
postgres:
9+
image: postgres:16-alpine
10+
container_name: cloud-connect-dev-postgres
11+
environment:
12+
POSTGRES_USER: spice_reader
13+
POSTGRES_DB: spice_demo
14+
# `:?` fails the command with this message rather than starting a
15+
# container with an empty password.
16+
POSTGRES_PASSWORD: ${SPICE_DEMO_PG_PASSWORD:?export SPICE_DEMO_PG_PASSWORD first — see step 3 of the README}
17+
ports:
18+
# 55432, not 5432: a development machine often already has a Postgres.
19+
- "55432:5432"
20+
volumes:
21+
- ./init:/docker-entrypoint-initdb.d:ro
22+
healthcheck:
23+
test: ["CMD-SHELL", "pg_isready -U spice_reader -d spice_demo"]
24+
interval: 5s
25+
timeout: 5s
26+
retries: 12
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- Seeded once, on the container's first start.
2+
CREATE TABLE public.orders (
3+
id integer PRIMARY KEY,
4+
customer text NOT NULL,
5+
total_cents integer NOT NULL,
6+
placed_at timestamptz NOT NULL
7+
);
8+
9+
INSERT INTO public.orders (id, customer, total_cents, placed_at) VALUES
10+
(1, 'acme', 12900, '2026-01-04T09:15:00Z'),
11+
(2, 'globex', 4550, '2026-01-04T11:02:00Z'),
12+
(3, 'initech', 31875, '2026-01-05T14:40:00Z'),
13+
(4, 'acme', 990, '2026-01-06T08:05:00Z'),
14+
(5, 'umbrella', 7625, '2026-01-06T16:22:00Z');

cloud-connect-dev/validate.sh

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,64 @@ else
213213
fi
214214
echo
215215

216+
# --- The secrets step is internally consistent and carries no credential. ---
217+
#
218+
# None of this needs Spice Cloud: it checks that the recipe asks for the
219+
# password from a delivered secret rather than a literal, that the container
220+
# refuses to start without one, and that the three places naming the database
221+
# agree with each other.
222+
echo "Secrets sync"
223+
if grep -q '\${secrets:pg_password}' README.md; then
224+
ok "the deployed Spicepod resolves the password from \${secrets:pg_password}"
225+
else
226+
no "the README's Spicepod does not use \${secrets:pg_password}"
227+
fi
228+
if grep -q 'secrets:' spicepod.yaml; then
229+
no "the local spicepod declares a secrets: section" "delivered secrets are a built-in store; declaring one makes the Spicepod unportable"
230+
else
231+
ok "no secrets: section is declared — the delivered store is built in"
232+
fi
233+
if grep -qE 'POSTGRES_PASSWORD: \$\{SPICE_DEMO_PG_PASSWORD:\?' docker-compose.yml; then
234+
ok "compose reads the password from the environment and fails loudly without it"
235+
else
236+
no "compose does not read POSTGRES_PASSWORD from the environment with a :? guard"
237+
fi
238+
if grep -q 'CREATE TABLE public.orders' init/01-orders.sql; then
239+
ok "the seed creates the table the README queries"
240+
else
241+
no "init/01-orders.sql does not create public.orders"
242+
fi
243+
244+
# The published port, the Spicepod's pg_port, and the psql examples must agree,
245+
# or the reader follows three different databases.
246+
# Quote-agnostic: a formatter may normalise '55432' to "55432", and a check that
247+
# reads only one spelling reports a mismatch that does not exist.
248+
compose_port="$(grep -oE '[0-9]{4,5}:5432' docker-compose.yml | head -1 | cut -d: -f1)"
249+
readme_port="$(grep -oE 'pg_port: .?[0-9]{4,5}' README.md | head -1 | grep -oE '[0-9]{4,5}')"
250+
if [ -n "$compose_port" ] && [ "$compose_port" = "$readme_port" ]; then
251+
ok "compose publishes $compose_port and the Spicepod connects to it"
252+
else
253+
no "port mismatch: compose publishes '${compose_port:-?}', the README uses '${readme_port:-?}'"
254+
fi
255+
256+
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
257+
if SPICE_DEMO_PG_PASSWORD=validate-only docker compose config >/dev/null 2>&1; then
258+
ok "docker-compose.yml parses"
259+
else
260+
no "docker-compose.yml does not parse" "$(SPICE_DEMO_PG_PASSWORD=validate-only docker compose config 2>&1 | head -3)"
261+
fi
262+
# The guard is the point: an unset password must stop the command, not start
263+
# a database that accepts an empty one.
264+
if (unset SPICE_DEMO_PG_PASSWORD; docker compose config >/dev/null 2>&1); then
265+
no "compose accepts an unset SPICE_DEMO_PG_PASSWORD"
266+
else
267+
ok "compose refuses to run with SPICE_DEMO_PG_PASSWORD unset"
268+
fi
269+
else
270+
printf ' skip docker not available; compose file not parsed\n'
271+
fi
272+
echo
273+
216274
printf '%d passed, %d failed\n' "$pass" "$fail"
217275
if [ "$fail" -eq 0 ]; then
218276
echo "TEST PASSED"

0 commit comments

Comments
 (0)