Skip to content

feat(pulumi-esc): add Pulumi ESC provider and lease backend#507

Draft
NorthIsUp wants to merge 13 commits into
jdx:mainfrom
NorthIsUp:feat/pulumi-esc-provider
Draft

feat(pulumi-esc): add Pulumi ESC provider and lease backend#507
NorthIsUp wants to merge 13 commits into
jdx:mainfrom
NorthIsUp:feat/pulumi-esc-provider

Conversation

@NorthIsUp

Copy link
Copy Markdown

Replaces #429, which could not be reopened: this branch was rebased onto the latest main (picking up the fnox-core crate extraction, FOKS, github-oauth, keychain/keyring fixes, etc.), so the commit #429 was closed at is no longer in the branch lineage and GitHub blocks reopening. Opening fresh here. Original review discussion lives on #429.

Summary

Add Pulumi ESC (Environments, Secrets, Configuration) support to fnox as both a secret provider and a lease backend, with native HTTP throughout — no esc CLI dependency.

  • Provider (crates/fnox-core/src/providers/pulumi_esc.rs): fetch individual values from an ESC environment via dot-path (e.g. anthropic.api_keyproperties.anthropic.value.api_key.value). Single-get and batch (one env open, multiple local extractions) share the same code path.
  • Lease backend (crates/fnox-core/src/lease_backends/pulumi_esc.rs): mint short-lived credentials by opening an ESC environment and surfacing entries from its environmentVariables block — lets ESC-minted dynamic credentials (AWS OIDC, GCP OIDC, Vault, etc.) flow into fnox's lease ledger.
  • Shared REST client (crates/fnox-core/src/pulumi_esc_api.rs): both call sites use one EscClient with one credential resolver, one POST /open + GET /open/{id} pair, and one properties-tree walker.

Config

fnox.toml:

[providers.esc]
type         = "pulumi-esc"
organization = "myorg"
project      = "myproj"      # optional; omit for legacy <org>/<env> refs
environment  = "dev"
token        = "pul-..."     # optional; see "Credential resolution" for details

[secrets]
DB_PASSWORD = { value = "database.password", provider = "esc" }

[leases.esc]
type         = "pulumi-esc"
organization = "myorg"
project      = "myproj"
environment  = "dev"
interpolate  = "%"                       # optional sigil char
env_vars     = ["AWS_ACCESS_KEY_ID"]     # optional filter

Credential resolution (matches esc CLI)

  1. token in TOML config
  2. FNOX_PULUMI_ACCESS_TOKEN
  3. PULUMI_ACCESS_TOKEN
  4. $PULUMI_HOME/credentials.json
  5. ~/.pulumi/credentials.json

The file's current field dictates the API base URL, so self-hosted Pulumi Cloud works without extra config. The env-var path honors PULUMI_BACKEND_URL.

interpolate option (lease backend)

Pulumi ESC allows composition + importing of secret environments, but it does early binding with variables. The interpolation option allows late binding.

When set to a single-char sigil (e.g. "%"), single-pass <sigil>{path} substitution runs on each surfaced env var. ANTHROPIC_API_KEY = "%{anthropic.api_key}" in an ESC env's environmentVariables block resolves to the real token.

Note: missing refs are a hard error. The feature is single-pass only — %{foo.%{bar}} is not recursively resolved and will error.

Notes

  • Env-ref path segments are percent-encoded before going into the request URL.
  • All Pulumi ESC unit tests pass; mise run lint (clippy + prettier) and mise run render are clean.

🤖 Generated with Claude Code

NorthIsUp and others added 12 commits May 28, 2026 16:51
Provider fetches individual values from ESC environments via `esc env
get <ref> <path> --value string --show-secrets`, with a batch path that
opens the environment once and extracts dot-delimited paths from the
resolved JSON.

Lease backend runs `esc env open --format json` and surfaces entries
from `environmentVariables` so ESC-minted dynamic credentials (AWS
OIDC, GCP OIDC, etc.) can be consumed through fnox's lease system.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace tokio::process::Command shell-out with direct calls to the
Pulumi ESC REST API (POST /api/esc/environments/{ref}/open, then GET
.../open/{id}). Removes the hard dependency on the `esc` CLI for lease
creation.

Credential resolution now mirrors the esc CLI: config token →
FNOX_PULUMI_ACCESS_TOKEN → PULUMI_ACCESS_TOKEN → $PULUMI_HOME or
~/.pulumi credentials.json. The `current` backend URL from the file is
honored so self-hosted Pulumi Cloud works without extra config.
check_prerequisites runs the real resolver instead of the broken
"must have PULUMI_ACCESS_TOKEN" check.

Add an `interpolate` option (char, default none) on the pulumi-esc
lease backend. When set (e.g. `interpolate = "%"`), single-pass
`<sigil>{path}` substitution runs on each surfaced env var, resolving
against the response's properties tree. Missing references are a hard
error. No recursion — `%{foo.%{bar}}` parses as `%{foo.%{bar}` and
errors on lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the shared ESC REST client into `src/pulumi_esc_api.rs`
(credential discovery, env_ref builder, `EscClient` with `open`/`read`,
and a `properties`-tree `lookup` walker). Both the lease backend and
the secret provider now drive this module instead of shelling out to
the `esc` CLI.

Provider: fetches individual values via POST /open + GET /open/{id},
extracting dot-delimited paths locally (both single-get and batch).
Drops `tokio::process::Command`, the `classify_cli_error` heuristics,
and the separate `walk`/`extract_path`/`value_to_string` helpers —
the shared `lookup` walker covers all paths the CLI JSON format did.

Lease backend: same refactor, plus it now re-uses the shared auth
resolver for `check_prerequisites`. `resolve_refs` and `extract_value`
stay local since they're lease-specific.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- docs: both pulumi-esc.md files said the `esc` CLI was a prerequisite
  and described the old CLI shell-out (`esc env open --format json`,
  `esc env get ... --show-secrets`). Rewritten to reflect the native
  HTTP path. Kept `esc login` as one of the auth options since it
  populates `~/.pulumi/credentials.json`, which fnox still reads.
- `EscClient::open` now sends `Content-Type: application/json` and an
  explicit `{}` body on the POST so middleware/proxies that enforce
  a media type don't reject with 415.
- `pulumi_esc_api::lookup` now early-returns None for empty paths so
  the intent is explicit rather than relying on `cur.get("")`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR jdx#429 review feedback:
- Revert dev version bump in Cargo.toml / fnox.usage.kdl (jdx flagged as
  unintentional). The fork-only dev tag is no longer needed.
- Switch std::env::var → crate::env::var for PULUMI_HOME and
  PULUMI_BACKEND_URL to match the rest of the codebase (gemini-code-assist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulumi Cloud org/project/env names are typically [a-zA-Z0-9-], but the
env ref is interpolated directly into the request path. Encoding each
segment is cheap insurance against a corrupted URL if a name ever
contains `/`, `?`, `#`, or whitespace.

Addresses Greptile P2 on PR jdx#429.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The provider metadata still pointed at the esc CLI for auth-prompting
and setup — contradicting the rest of the PR where fnox talks to the
Pulumi Cloud REST API directly. Drop auth_command (no CLI to invoke)
and rewrite setup_instructions to describe the token / credentials.json
paths.

Addresses Greptile P1 on PR jdx#429.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Move `## Authentication` to `### Authentication` under `## Configuration`
- Add missing `## Usage` section
- Rename `## Leases (dynamic credentials)` to `## Leases`
- Add `### GitHub Actions` subsection under `## CI/CD Example`

https://claude.ai/code/session_016UhCuqGUPhdmZ5sYXe3Y15
…ction

Module lives in crates/fnox-core/src/pulumi_esc_api.rs after the
fnox-core crate refactor.

https://claude.ai/code/session_016UhCuqGUPhdmZ5sYXe3Y15
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eview

The percent-encoding fix for env-ref path segments was applied to the
stale `src/pulumi_esc_api.rs` before the crate extraction and silently
lost when that file was deleted. `build_env_ref` shipped unencoded,
interpolating org/project/env straight into the request path. Restore
per-segment `urlencoding::encode` (the canonical idiom, as in
passwordstate) and add a test.

Quality cleanups from a code-quality review:
- Collapse the duplicate `{value, trace}` scalar coercion: the lease
  backend's `extract_value` and the api's inline match are now one
  shared `pulumi_esc_api::coerce_scalar`; `lookup` reuses it.
- Replace the dishonest `(String, String)` auth tuple with a named
  `EscAuth { base, token }` struct.
- Add `EscClient::{api_err, invalid_err}` helpers to remove the repeated
  provider/url error plumbing in `open`/`read`/`http_error`.
- Use `PROVIDER_NAME` instead of bare "Pulumi ESC" literals in the
  lease backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for Pulumi ESC as both a secret provider and a lease backend, allowing fnox to retrieve secrets and dynamic credentials directly from Pulumi ESC environments via the Pulumi Cloud REST API. The feedback recommends using the custom env::var wrapper instead of std::env::var in pulumi_esc_api.rs to maintain consistent environment variable resolution and support test mocking. Additionally, it suggests replacing the deprecated chrono::Duration::seconds with chrono::Duration::from_std in the lease backend implementation.

Comment thread crates/fnox-core/src/pulumi_esc_api.rs Outdated
Comment thread crates/fnox-core/src/pulumi_esc_api.rs Outdated
Comment thread crates/fnox-core/src/pulumi_esc_api.rs Outdated
Comment on lines +166 to +167
let expires_at =
Some(chrono::Utc::now() + chrono::Duration::seconds(duration.as_secs() as i64));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use chrono::Duration::from_std to convert the std::time::Duration to a chrono::Duration. This avoids the deprecated chrono::Duration::seconds function and safely handles sub-second precision.

Suggested change
let expires_at =
Some(chrono::Utc::now() + chrono::Duration::seconds(duration.as_secs() as i64));
let expires_at =
Some(chrono::Utc::now() + chrono::Duration::from_std(duration).unwrap());

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Holding off on this one. chrono::Duration::seconds isn't deprecated in our pinned chrono 0.4 (build emits no deprecation warning), lease durations are whole-second and bounded (max 3600s), and from_std(duration).unwrap() would introduce a panic path for no functional gain here. Keeping the current form.

This reply was generated by Claude Code.

@greptile-apps

greptile-apps Bot commented May 29, 2026

Copy link
Copy Markdown

Greptile Summary

Adds Pulumi ESC as both a secret provider and a lease backend, using native HTTP against the Pulumi Cloud REST API (no esc CLI dependency). The shared EscClient in pulumi_esc_api.rs handles credential discovery, the open/read two-step flow, and the properties-tree walker used by both call sites.

  • Provider (providers/pulumi_esc.rs): fetches individual secrets by dot-path via open_and_read; batch mode opens the environment once and extracts paths locally.
  • Lease backend (lease_backends/pulumi_esc.rs): opens the environment, surfaces entries from environmentVariables, and supports optional single-pass <sigil>{path} interpolation for late binding.
  • Docs & schema (docs/, schema.json): provider and lease pages, nav entries, CLI choices, and JSON schema updated consistently.

Confidence Score: 4/5

Safe to merge after fixing check_prerequisites; actual lease creation and secret resolution are unaffected by the bug.

The lease backend's check_prerequisites() ignores the config-supplied token and only inspects env vars and credentials.json. A user who configures token in TOML but has neither a PULUMI_ACCESS_TOKEN env var nor a ~/.pulumi/credentials.json will be told prerequisites are not met even though their configuration is valid — the actual create_lease path handles the config token correctly via resolve_auth_for. All other logic is solid and well-tested.

crates/fnox-core/src/lease_backends/pulumi_esc.rs and the PulumiEsc arm in crates/fnox-core/src/lease_backends/mod.rs

Important Files Changed

Filename Overview
crates/fnox-core/src/lease_backends/pulumi_esc.rs New lease backend for Pulumi ESC; check_prerequisites() doesn't receive the config token, causing false prerequisite failures for users with token in TOML only
crates/fnox-core/src/lease_backends/mod.rs PulumiEsc added to LeaseBackendConfig; check_prerequisites call uses .. and drops the token field, unlike GithubApp and Vault which forward config values
crates/fnox-core/src/pulumi_esc_api.rs New shared REST client for Pulumi ESC; credential resolution, URL building, lookup, and HTTP open/read flow look correct; env vars use project convention
crates/fnox-core/src/providers/pulumi_esc.rs New secret provider; correctly uses resolve_auth_for with config token, batch fetch opens env once, tests cover single and batch paths
crates/fnox-core/providers/pulumi-esc.toml New provider descriptor with required/optional fields; looks complete and consistent with other providers
docs/public/schema.json Schema additions for PulumiEsc lease backend and provider look correct; required fields match implementation
src/commands/provider/add.rs PulumiEsc branch added to provider add command with placeholder defaults; matches existing patterns
src/commands/provider/mod.rs PulumiEsc variant added to ProviderType enum with correct strum/clap value names

Reviews (2): Last reviewed commit: "chore(review): route PULUMI_HOME/PULUMI_..." | Re-trigger Greptile

Comment thread crates/fnox-core/src/pulumi_esc_api.rs
Comment thread crates/fnox-core/src/pulumi_esc_api.rs
Comment thread crates/fnox-core/src/pulumi_esc_api.rs
Address gemini-code-assist and greptile review on jdx#507: use the project's
`env::var` wrapper (re-exported, test-mockable) instead of `std::env::var`
for PULUMI_HOME and PULUMI_BACKEND_URL, matching the convention used by the
token lookups and every other provider.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@NorthIsUp

Copy link
Copy Markdown
Author

Thanks @gemini-code-assist and @greptile-apps — addressed in d06b57e:

  • std::env::varcrate::env::var for PULUMI_HOME and PULUMI_BACKEND_URL (3 sites). Matches the project convention (CLAUDE.md) and the existing PULUMI_ACCESS_TOKEN lookups in the same file.
  • chrono::Duration::secondsfrom_std: declined. It isn't deprecated in our pinned chrono 0.4 (no build warning), and from_std(duration).unwrap() would add a panic path for no functional gain on whole-second, bounded lease durations. Details on the inline thread.

This comment was generated by Claude Code.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Thanks for the update, @NorthIsUp. The switch to crate::env::var aligns perfectly with the project's conventions. Your reasoning regarding chrono::Duration::seconds is sound—maintaining stability and avoiding unnecessary panics is a priority. Everything looks good.

@greptile-apps

greptile-apps Bot commented May 29, 2026

Copy link
Copy Markdown

Thanks for the follow-up. The crate::env::var fixes look good.

On chrono::Duration::seconds — fair point. from_std returning Result does introduce an unnecessary panic path for bounded whole-second values, and if there's no build warning on your pinned version, keeping Duration::seconds is the right call. No objection.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants