Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,21 @@ Details: [docs/Env-config-aliases.md](docs/Env-config-aliases.md).

### Versions / releases (snapshots)

You can save and use snapshots of your app state in the cloud:
You can save and use snapshots of your app state in the cloud. Releases are **always encrypted** and require `ENSEMBLE_ENCRYPTION_KEY` in your alias secrets file:

- **Create a release from local state:** After you have local changes you want to “tag”, run **`ensemble release create`** to save a snapshot (release) of the **current local app state** with an optional message.
- **List releases:** Run **`ensemble release list`** to see recent releases.
- **Use a release locally:** Run **`ensemble release use`** to choose a release and update **local files only** to that snapshot. Then run **`ensemble push`** to apply that state to the cloud.
```bash
openssl rand -hex 32 # add to .env.secrets or .env.secrets.<alias>
```

`release create` and `release use` read the same alias-scoped secrets file as `push` / `pull`.

- **Create:** `ensemble release create` — encrypts snapshot (AES-256-GCM) to `.enc.json` in Storage.
- **List:** `ensemble release list`
- **Use:** `ensemble release use` — downloads from Storage (Firebase auth), decrypts locally, restores files + secrets.

Legacy plain `.json` releases are not supported. Re-create after adding the encryption key.

**Firebase Storage rules (prod):** Restrict `releases/{appId}/*` to app collaborators via Firestore `get()` (see team lead / ops). Encryption protects snapshot contents; rules control who can download ciphertext.

When you run `ensemble release` **without a subcommand** in an interactive terminal, the CLI opens an interactive menu that lets you choose between **create**, **list**, and **use**. In non-interactive environments (e.g. CI), you must call an explicit subcommand such as `ensemble release list` or `ensemble release use --hash <hash>`.

Expand Down
12 changes: 11 additions & 1 deletion docs/Env-config-aliases.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,17 @@ Commands use the resolved pair for the selected `--app` alias (see resolution ru

### Release use

- `ensemble release use` restores snapshot config into the same write target as pull (scoped or base).
- `ensemble release use` restores snapshot config and secrets into the same write targets as pull (scoped or base).
- `release create` and `release use` require `ENSEMBLE_ENCRYPTION_KEY` in the alias secrets file (`.env.secrets` or `.env.secrets.<alias>`). Generate with `openssl rand -hex 32`.
- Snapshots are stored encrypted as `.enc.json` in Firebase Storage. Download uses normal Firebase Storage auth; access is enforced by Storage rules (app collaborators).

### Release encryption (CDN vs CLI)

| Key | CDN publish | CLI releases |
| ------------------------- | ---------------------------------------- | ------------------------------------------------ |
| `ENSEMBLE_ENCRYPTION_KEY` | Encrypts `encrypted-manifest.json` on R2 | Encrypts release `.enc.json` on Firebase Storage |

CDN may also use `ENSEMBLE_MANIFEST_KEY` for Cloudflare WAF. CLI releases do not use a manifest key.

---

Expand Down
33 changes: 21 additions & 12 deletions src/cloud/storageClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export class StorageClientError extends Error {
status?: number;
hint?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cause?: any;
cause?: unknown;

constructor(params: { message: string; status?: number; hint?: string; cause?: unknown }) {
super(params.message);
Expand All @@ -20,13 +20,11 @@ export interface UploadReleaseSnapshotResult {
objectPath: string;
}

function objectPathForRelease(appId: string, versionId: string): string {
return `releases/${appId}/${versionId}.json`;
export function objectPathForRelease(appId: string, versionId: string): string {
return `releases/${appId}/${versionId}.enc.json`;
}

function storageAuthHeader(idToken: string): string {
// Firebase Storage v0 API accepts Firebase Auth tokens.
// Note: This is different from Google Cloud Storage OAuth tokens.
return `Firebase ${idToken}`;
}

Expand All @@ -37,17 +35,28 @@ async function toStorageError(context: string, res: Response): Promise<StorageCl
status: res.status,
hint:
res.status === 401 || res.status === 403
? 'Authentication/authorization failed for Storage. Check your login session and Storage rules/IAM.'
: undefined,
? 'Authentication/authorization failed for Storage. Check your login session and Storage rules for releases/*.'
: res.status === 415
? 'Storage rejected the upload content type. Retry after updating the CLI.'
: undefined,
cause: text.slice(0, 500),
});
}

function toFetchBody(body: Buffer | string): BodyInit {
if (typeof body === 'string') return body;
const arrayBuffer = body.buffer.slice(
body.byteOffset,
body.byteOffset + body.byteLength
) as ArrayBuffer;
return new Uint8Array(arrayBuffer);
}

export async function uploadReleaseSnapshot(
appId: string,
idToken: string,
versionId: string,
snapshotJson: string
body: Buffer | string
): Promise<UploadReleaseSnapshotResult> {
const bucket = `${getEnsembleFirebaseProject()}.appspot.com`;
const objectPath = objectPathForRelease(appId, versionId);
Expand All @@ -61,7 +70,7 @@ export async function uploadReleaseSnapshot(
Authorization: storageAuthHeader(idToken),
'Content-Type': 'application/json',
},
body: snapshotJson,
body: toFetchBody(body),
});

if (!res.ok) {
Expand All @@ -73,12 +82,12 @@ export async function uploadReleaseSnapshot(

export async function downloadReleaseSnapshotJson(
idToken: string,
snapshotPath: string
objectPath: string
): Promise<string> {
const bucket = `${getEnsembleFirebaseProject()}.appspot.com`;
const url = `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(
bucket
)}/o/${encodeURIComponent(snapshotPath)}?alt=media`;
)}/o/${encodeURIComponent(objectPath)}?alt=media`;

const res = await fetch(url, {
headers: {
Expand All @@ -90,5 +99,5 @@ export async function downloadReleaseSnapshotJson(
throw await toStorageError('download release snapshot', res);
}

return await res.text();
return res.text();
}
5 changes: 3 additions & 2 deletions src/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ export async function pullCommand(options: PullOptions = {}): Promise<void> {

await withSpinner('Writing local files...', async () => {
await applyCloudStateToFs(projectRoot, cloudApp, localFiles, enabledByProp, {
manifestOptions: {},
refreshManifest: true,
onProgress: (completed, total) => {
// eslint-disable-next-line no-console
console.log(`Writing files... (${completed}/${total})`);
Expand Down Expand Up @@ -349,7 +349,8 @@ export async function pullCommand(options: PullOptions = {}): Promise<void> {
(fileName): fileName is string => typeof fileName === 'string' && fileName.length > 0
),
appKey,
config.default
config.default,
cloudApp.assets
);
});

Expand Down
2 changes: 1 addition & 1 deletion src/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
// Only refresh manifest when artifact changes can affect its contents.
try {
await withSpinner('Refreshing local manifest...', async () => {
await buildAndWriteManifest(root, bundle as CloudApp, {});
await buildAndWriteManifest(root, bundle as CloudApp);
});
} catch (manifestErr) {
if (verbose) {
Expand Down
Loading
Loading