fix(coolify): strip is_build_time from env writes; add reveal + GCS
Coolify v4's POST/PATCH /applications/{uuid}/envs only accepts key,
value, is_preview, is_literal, is_multiline, is_shown_once. Sending
is_build_time triggers a 422 "This field is not allowed." — it's now
a derived read-only flag (is_buildtime) computed from Dockerfile ARG
usage. Breaks agents trying to upsert env vars.
Three-layer fix so this can't regress:
- lib/coolify.ts: COOLIFY_ENV_WRITE_FIELDS whitelist enforced at the
network boundary, regardless of caller shape
- app/api/workspaces/[slug]/apps/[uuid]/envs: stops forwarding the
field; returns a deprecation warning when callers send it; GET
reads both is_buildtime and is_build_time for version parity
- app/api/mcp/route.ts: same treatment in the MCP dispatcher;
AI_CAPABILITIES.md doc corrected
Also bundles (not related to the above):
- Workspace API keys are now revealable from settings. New
key_encrypted column stores AES-256-GCM(VIBN_SECRETS_KEY, token).
POST /api/workspaces/[slug]/keys/[keyId]/reveal returns plaintext
for session principals only; API-key principals cannot reveal
siblings. Legacy keys stay valid for auth but can't reveal.
- P5.3 Object storage: lib/gcp/storage.ts + lib/workspace-gcs.ts
idempotently provision a per-workspace GCS bucket, service
account, IAM binding and HMAC key. New POST /api/workspaces/
[slug]/storage/buckets endpoint. Migration script + smoke test
included. Proven end-to-end against prod master-ai-484822.
Made-with: Cursor
This commit is contained in:
415
scripts/smoke-storage-e2e.ts
Normal file
415
scripts/smoke-storage-e2e.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* P5.3 — End-to-end smoke for per-workspace GCS provisioning.
|
||||
*
|
||||
* What this exercises (against PROD GCP — master-ai-484822):
|
||||
* 1. ensureWorkspaceServiceAccount → creates a throwaway SA
|
||||
* (vibn-ws-smoke-{ts}@…). Idempotent.
|
||||
* 2. createServiceAccountKey → mints + base64-encodes a JSON key.
|
||||
* 3. createBucket → creates vibn-ws-smoke-{ts}-{6char}
|
||||
* in northamerica-northeast1 with uniform bucket-level access ON
|
||||
* and public access prevention enforced.
|
||||
* 4. addBucketIamBinding → grants the throwaway SA
|
||||
* roles/storage.objectAdmin on the bucket only.
|
||||
* 5. createHmacKey → mints S3-compatible HMAC creds
|
||||
* tied to the throwaway SA.
|
||||
* 6. (verify) HMAC PUT/GET → uploads a 12-byte object via the
|
||||
* GCS XML API using AWS SigV4 with the HMAC creds, reads it back,
|
||||
* deletes it. Proves the credentials actually work.
|
||||
*
|
||||
* Cleanup (best-effort, runs even on failure):
|
||||
* - Deletes the test object.
|
||||
* - Deactivates + deletes the HMAC key.
|
||||
* - Deletes all keys on the SA (so the SA itself can be removed).
|
||||
* - Deletes the bucket.
|
||||
* - Deletes the SA.
|
||||
*
|
||||
* NO Postgres writes. NO Coolify writes. NO project-level IAM changes.
|
||||
* Everything created has a "smoke-" prefix and a "purpose=smoke" label
|
||||
* so leftovers are obvious in the GCP console.
|
||||
*
|
||||
* Required env (load from /Users/markhenderson/master-ai/.google.env):
|
||||
* GOOGLE_SERVICE_ACCOUNT_KEY_B64 base64 of vibn-workspace-provisioner SA JSON
|
||||
* GCP_PROJECT_ID defaults to master-ai-484822
|
||||
*
|
||||
* Usage:
|
||||
* cd vibn-frontend
|
||||
* npx -y dotenv-cli -e ../.google.env -- npx tsx scripts/smoke-storage-e2e.ts
|
||||
*/
|
||||
|
||||
import { createHash, createHmac } from 'crypto';
|
||||
import { GCP_PROJECT_ID } from '../lib/gcp-auth';
|
||||
import {
|
||||
ensureWorkspaceServiceAccount,
|
||||
createServiceAccountKey,
|
||||
workspaceServiceAccountEmail,
|
||||
workspaceServiceAccountId,
|
||||
} from '../lib/gcp/iam';
|
||||
import {
|
||||
createBucket,
|
||||
deleteBucket,
|
||||
addBucketIamBinding,
|
||||
getBucketIamPolicy,
|
||||
createHmacKey,
|
||||
deleteHmacKey,
|
||||
workspaceDefaultBucketName,
|
||||
VIBN_GCS_LOCATION,
|
||||
} from '../lib/gcp/storage';
|
||||
|
||||
const ts = Date.now().toString(36);
|
||||
const SLUG = `smoke-${ts}`;
|
||||
const SA_EMAIL = workspaceServiceAccountEmail(SLUG);
|
||||
const SA_ID = workspaceServiceAccountId(SLUG);
|
||||
const BUCKET = workspaceDefaultBucketName(SLUG);
|
||||
const TEST_OBJECT_KEY = 'smoke/hello.txt';
|
||||
const TEST_OBJECT_BODY = 'vibn smoke ✓';
|
||||
|
||||
function banner(): void {
|
||||
console.log('━'.repeat(72));
|
||||
console.log(' VIBN P5.3 GCS provisioning smoke (PROD GCP — master-ai-484822)');
|
||||
console.log('━'.repeat(72));
|
||||
console.log(` project : ${GCP_PROJECT_ID}`);
|
||||
console.log(` slug : ${SLUG}`);
|
||||
console.log(` SA : ${SA_EMAIL}`);
|
||||
console.log(` bucket : ${BUCKET}`);
|
||||
console.log(` location : ${VIBN_GCS_LOCATION}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
interface State {
|
||||
saCreated: boolean;
|
||||
saKeyName?: string;
|
||||
bucketCreated: boolean;
|
||||
hmacAccessId?: string;
|
||||
uploadedObject: boolean;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
banner();
|
||||
const state: State = { saCreated: false, bucketCreated: false, uploadedObject: false };
|
||||
|
||||
try {
|
||||
// ── 1. Service account ────────────────────────────────────────────
|
||||
console.log('[1/6] Ensure service account…');
|
||||
const sa = await ensureWorkspaceServiceAccount({ slug: SLUG, workspaceName: SLUG });
|
||||
state.saCreated = true;
|
||||
console.log(` ✓ ${sa.email}`);
|
||||
|
||||
// ── 2. Service-account key ────────────────────────────────────────
|
||||
console.log('[2/6] Mint service-account JSON key…');
|
||||
const key = await createServiceAccountKey(sa.email);
|
||||
state.saKeyName = key.name;
|
||||
console.log(` ✓ key.name=${key.name.split('/').slice(-1)[0]} (privateKeyData ${key.privateKeyData.length} chars b64)`);
|
||||
|
||||
// ── 3. Bucket ────────────────────────────────────────────────────
|
||||
console.log('[3/6] Create bucket (uniform BLA on, public-access prevention enforced)…');
|
||||
const bucket = await createBucket({
|
||||
name: BUCKET,
|
||||
location: VIBN_GCS_LOCATION,
|
||||
enforcePublicAccessPrevention: true,
|
||||
workspaceSlug: SLUG,
|
||||
});
|
||||
state.bucketCreated = true;
|
||||
console.log(` ✓ ${bucket.name} in ${bucket.location}`);
|
||||
|
||||
// ── 4. Bucket IAM binding ────────────────────────────────────────
|
||||
console.log('[4/6] Add roles/storage.objectAdmin binding for the workspace SA…');
|
||||
await addBucketIamBinding({
|
||||
bucketName: bucket.name,
|
||||
role: 'roles/storage.objectAdmin',
|
||||
member: `serviceAccount:${sa.email}`,
|
||||
});
|
||||
const policy = await getBucketIamPolicy(bucket.name);
|
||||
const binding = policy.bindings?.find(
|
||||
b => b.role === 'roles/storage.objectAdmin' && b.members.includes(`serviceAccount:${sa.email}`),
|
||||
);
|
||||
if (!binding) {
|
||||
throw new Error('IAM binding did not stick — workspace SA not in objectAdmin members');
|
||||
}
|
||||
console.log(` ✓ binding present (${binding.members.length} member(s) on ${binding.role})`);
|
||||
|
||||
// ── 5. HMAC key ──────────────────────────────────────────────────
|
||||
console.log('[5/6] Mint HMAC key for the workspace SA…');
|
||||
const hmac = await createHmacKey(sa.email);
|
||||
state.hmacAccessId = hmac.accessId;
|
||||
console.log(` ✓ accessId=${hmac.accessId} state=${hmac.state}`);
|
||||
|
||||
// HMAC keys take a few seconds to become usable on the GCS XML API.
|
||||
// Without this delay we usually get "InvalidAccessKeyId" on the
|
||||
// very first request.
|
||||
console.log(' … waiting 6s for HMAC propagation');
|
||||
await sleep(6000);
|
||||
|
||||
// ── 6. Verify HMAC creds work via S3-compatible XML API ─────────
|
||||
console.log('[6/6] PUT / GET / DELETE a tiny object via the XML API using HMAC creds…');
|
||||
await s3PutObject({
|
||||
accessKeyId: hmac.accessId,
|
||||
secretAccessKey: hmac.secret,
|
||||
bucket: bucket.name,
|
||||
key: TEST_OBJECT_KEY,
|
||||
body: Buffer.from(TEST_OBJECT_BODY, 'utf-8'),
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
});
|
||||
state.uploadedObject = true;
|
||||
console.log(` ✓ PUT ${TEST_OBJECT_KEY}`);
|
||||
|
||||
const got = await s3GetObject({
|
||||
accessKeyId: hmac.accessId,
|
||||
secretAccessKey: hmac.secret,
|
||||
bucket: bucket.name,
|
||||
key: TEST_OBJECT_KEY,
|
||||
});
|
||||
if (got.toString('utf-8') !== TEST_OBJECT_BODY) {
|
||||
throw new Error(`GET body mismatch: ${JSON.stringify(got.toString('utf-8'))}`);
|
||||
}
|
||||
console.log(` ✓ GET round-trip body matches`);
|
||||
|
||||
await s3DeleteObject({
|
||||
accessKeyId: hmac.accessId,
|
||||
secretAccessKey: hmac.secret,
|
||||
bucket: bucket.name,
|
||||
key: TEST_OBJECT_KEY,
|
||||
});
|
||||
state.uploadedObject = false;
|
||||
console.log(` ✓ DELETE`);
|
||||
|
||||
console.log('');
|
||||
console.log('━'.repeat(72));
|
||||
console.log(' SUMMARY');
|
||||
console.log('━'.repeat(72));
|
||||
console.log(' SA create+key : ✓');
|
||||
console.log(' Bucket create : ✓');
|
||||
console.log(' Bucket IAM binding : ✓');
|
||||
console.log(' HMAC key + S3 round-trip : ✓');
|
||||
console.log('');
|
||||
console.log(' All 4 building blocks of P5.3 vertical slice proven against PROD GCP.');
|
||||
} catch (err) {
|
||||
console.error('');
|
||||
console.error('[smoke-storage-e2e] FAILED:', err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
console.log('');
|
||||
console.log('Cleanup…');
|
||||
await cleanup(state).catch(err => {
|
||||
console.error('[cleanup] non-fatal error:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(state: State): Promise<void> {
|
||||
// Object (best-effort; usually already deleted on the happy path).
|
||||
if (state.uploadedObject && state.hmacAccessId) {
|
||||
// The credential needed to delete the object lives only in the
|
||||
// smoke run's memory; if we crashed before saving the secret,
|
||||
// we can't delete it as the workspace SA. Fall back to deleting
|
||||
// the bucket which atomically removes contents (deleteBucket
|
||||
// requires an empty bucket — use force-delete via objects.delete
|
||||
// listing if it ever matters).
|
||||
}
|
||||
|
||||
// HMAC key.
|
||||
if (state.hmacAccessId) {
|
||||
try {
|
||||
await deleteHmacKey(state.hmacAccessId);
|
||||
console.log(` ✓ HMAC ${state.hmacAccessId} deleted`);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ HMAC delete failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Bucket. Must be empty; if a test object survived, list+delete first.
|
||||
if (state.bucketCreated) {
|
||||
try {
|
||||
// Try a hard delete; if the bucket has objects we'll get 409.
|
||||
await deleteBucket(BUCKET);
|
||||
console.log(` ✓ bucket ${BUCKET} deleted`);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ bucket delete failed (objects may remain):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// SA keys + SA itself.
|
||||
if (state.saCreated) {
|
||||
try {
|
||||
await deleteAllSaKeysAndSa(SA_EMAIL);
|
||||
console.log(` ✓ SA ${SA_EMAIL} + keys deleted`);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ SA cleanup failed:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Helpers — SA cleanup using the IAM API directly (the lib only exposes
|
||||
// create paths).
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { getGcpAccessToken } from '../lib/gcp-auth';
|
||||
|
||||
async function deleteAllSaKeysAndSa(email: string): Promise<void> {
|
||||
const token = await getGcpAccessToken();
|
||||
const base = `https://iam.googleapis.com/v1/projects/${GCP_PROJECT_ID}/serviceAccounts/${encodeURIComponent(email)}`;
|
||||
|
||||
// Delete user-managed keys (system-managed keys can't be deleted).
|
||||
const listRes = await fetch(`${base}/keys?keyTypes=USER_MANAGED`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (listRes.ok) {
|
||||
const listJson = (await listRes.json()) as { keys?: { name: string }[] };
|
||||
for (const k of listJson.keys ?? []) {
|
||||
const id = k.name.split('/').pop();
|
||||
if (!id) continue;
|
||||
const delRes = await fetch(`${base}/keys/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!delRes.ok && delRes.status !== 404) {
|
||||
console.warn(` ⚠ key ${id} delete → ${delRes.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the SA.
|
||||
const delRes = await fetch(base, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!delRes.ok && delRes.status !== 404) {
|
||||
throw new Error(`SA delete → ${delRes.status} ${await delRes.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// AWS SigV4 against the GCS XML API
|
||||
//
|
||||
// We re-implement SigV4 here rather than pulling in @aws-sdk to keep
|
||||
// this script dependency-light. GCS treats the bucket as a virtual host
|
||||
// (https://{bucket}.storage.googleapis.com/{key}) and uses region
|
||||
// "auto" with service "s3".
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface S3Creds {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
}
|
||||
|
||||
async function s3PutObject(opts: S3Creds & {
|
||||
bucket: string;
|
||||
key: string;
|
||||
body: Buffer;
|
||||
contentType?: string;
|
||||
}): Promise<void> {
|
||||
const url = `https://${opts.bucket}.storage.googleapis.com/${encodeURIComponent(opts.key)}`;
|
||||
const res = await sigv4Fetch({
|
||||
method: 'PUT',
|
||||
url,
|
||||
body: opts.body,
|
||||
contentType: opts.contentType,
|
||||
accessKeyId: opts.accessKeyId,
|
||||
secretAccessKey: opts.secretAccessKey,
|
||||
});
|
||||
if (!res.ok) throw new Error(`PUT ${opts.key} → ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function s3GetObject(opts: S3Creds & { bucket: string; key: string }): Promise<Buffer> {
|
||||
const url = `https://${opts.bucket}.storage.googleapis.com/${encodeURIComponent(opts.key)}`;
|
||||
const res = await sigv4Fetch({
|
||||
method: 'GET',
|
||||
url,
|
||||
accessKeyId: opts.accessKeyId,
|
||||
secretAccessKey: opts.secretAccessKey,
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET ${opts.key} → ${res.status} ${await res.text()}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
async function s3DeleteObject(opts: S3Creds & { bucket: string; key: string }): Promise<void> {
|
||||
const url = `https://${opts.bucket}.storage.googleapis.com/${encodeURIComponent(opts.key)}`;
|
||||
const res = await sigv4Fetch({
|
||||
method: 'DELETE',
|
||||
url,
|
||||
accessKeyId: opts.accessKeyId,
|
||||
secretAccessKey: opts.secretAccessKey,
|
||||
});
|
||||
if (!res.ok && res.status !== 404) {
|
||||
throw new Error(`DELETE ${opts.key} → ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface SigV4FetchOpts extends S3Creds {
|
||||
method: 'GET' | 'PUT' | 'DELETE';
|
||||
url: string;
|
||||
body?: Buffer;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
async function sigv4Fetch(opts: SigV4FetchOpts): Promise<Response> {
|
||||
const { method, url, body, contentType, accessKeyId, secretAccessKey } = opts;
|
||||
const u = new URL(url);
|
||||
const host = u.host;
|
||||
const path = u.pathname || '/';
|
||||
const query = u.search.slice(1);
|
||||
|
||||
const now = new Date();
|
||||
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||
const dateStamp = amzDate.slice(0, 8);
|
||||
const region = 'auto';
|
||||
const service = 's3';
|
||||
const payloadHash = body
|
||||
? createHash('sha256').update(body).digest('hex')
|
||||
: createHash('sha256').update('').digest('hex');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
host,
|
||||
'x-amz-date': amzDate,
|
||||
'x-amz-content-sha256': payloadHash,
|
||||
};
|
||||
if (contentType) headers['content-type'] = contentType;
|
||||
if (body) headers['content-length'] = String(body.length);
|
||||
|
||||
const signedHeaders = Object.keys(headers).map(k => k.toLowerCase()).sort().join(';');
|
||||
const canonicalHeaders =
|
||||
Object.keys(headers)
|
||||
.map(k => [k.toLowerCase(), String(headers[k]).trim()] as const)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}:${v}\n`)
|
||||
.join('');
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash,
|
||||
].join('\n');
|
||||
|
||||
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
||||
const stringToSign = [
|
||||
'AWS4-HMAC-SHA256',
|
||||
amzDate,
|
||||
credentialScope,
|
||||
createHash('sha256').update(canonicalRequest).digest('hex'),
|
||||
].join('\n');
|
||||
|
||||
const kDate = createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest();
|
||||
const kRegion = createHmac('sha256', kDate).update(region).digest();
|
||||
const kService = createHmac('sha256', kRegion).update(service).digest();
|
||||
const kSigning = createHmac('sha256', kService).update('aws4_request').digest();
|
||||
const signature = createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
||||
|
||||
const authorization =
|
||||
`AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, ` +
|
||||
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers: { ...headers, Authorization: authorization },
|
||||
body: body ? new Uint8Array(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user