fix: skip unchanged HWLAB test account sync
This commit is contained in:
@@ -149,7 +149,7 @@ async function sync(config: LoadedConfig, target: Target, options: Options): Pro
|
||||
for (const account of target.accounts.filter((item) => item.target.kind === "kubernetes-secret")) {
|
||||
const source = sources.find((item) => item.sourceRef === account.sourceRef && item.sourceKey === account.sourceKey);
|
||||
if (!source?.value) throw new Error(`${account.logicalId} source material missing after preflight`);
|
||||
synced.push(syncKubernetesSecret(target, account, source, options));
|
||||
synced.push(syncKubernetesSecretIfNeeded(target, account, source, options));
|
||||
}
|
||||
const userBillingAccounts = target.accounts.filter((item) => item.target.kind === "user-billing-api-key");
|
||||
if (userBillingAccounts.length === 0) {
|
||||
@@ -163,6 +163,11 @@ async function sync(config: LoadedConfig, target: Target, options: Options): Pro
|
||||
for (const account of userBillingAccounts) {
|
||||
const source = sources.find((item) => item.sourceRef === account.sourceRef && item.sourceKey === account.sourceKey);
|
||||
if (!source?.value || !source.sha256Hex || !source.serviceKeyPrefix) throw new Error(`${account.logicalId} source material missing after preflight`);
|
||||
const targetStatus = await userBillingApiKeyStatus(tx, account, source);
|
||||
if (userBillingAccountMatches(account, targetStatus)) {
|
||||
synced.push(skippedUserBillingAccountSync(account, source, targetStatus));
|
||||
continue;
|
||||
}
|
||||
await syncUserBillingAccount(tx, account, source);
|
||||
synced.push({ logicalId: account.logicalId, targetKind: account.target.kind, mutation: true, userId: account.userId, keyId: account.target.keyId, keyPrefix: source.serviceKeyPrefix, fingerprint: source.fingerprint, valuesRedacted: true });
|
||||
}
|
||||
@@ -296,6 +301,45 @@ VALUES (${account.target.keyId ?? ""}, ${account.userId}, ${account.target.keyNa
|
||||
ON CONFLICT (id) DO UPDATE SET user_id = EXCLUDED.user_id, name = EXCLUDED.name, key_prefix = EXCLUDED.key_prefix, key_hash = EXCLUDED.key_hash, scopes_json = EXCLUDED.scopes_json, status = 'active', revoked_at = NULL`;
|
||||
}
|
||||
|
||||
function syncKubernetesSecretIfNeeded(target: Target, account: Account, source: SourceMaterial, options: Options): Record<string, unknown> {
|
||||
const targetStatus = runtimeSecretStatus(target, account, source, options);
|
||||
if (targetStatus.matchesSourceFingerprint === true) return skippedKubernetesSecretSync(target, account, source, targetStatus);
|
||||
return syncKubernetesSecret(target, account, source, options);
|
||||
}
|
||||
|
||||
function skippedKubernetesSecretSync(target: Target, account: Account, source: SourceMaterial, targetStatus: Record<string, unknown>): Record<string, unknown> {
|
||||
const namespace = account.target.namespace ?? target.namespace;
|
||||
const secretName = account.target.secretName ?? "";
|
||||
return {
|
||||
logicalId: account.logicalId,
|
||||
targetKind: account.target.kind,
|
||||
namespace,
|
||||
secretName,
|
||||
targetKey: account.target.targetKey,
|
||||
rolloutDeployment: account.target.rolloutDeployment,
|
||||
mutation: false,
|
||||
skipped: "already-matches-source",
|
||||
target: compactTargetStatus(targetStatus),
|
||||
fingerprint: source.fingerprint,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function skippedUserBillingAccountSync(account: Account, source: SourceMaterial, targetStatus: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
logicalId: account.logicalId,
|
||||
targetKind: account.target.kind,
|
||||
mutation: false,
|
||||
skipped: "already-matches-source",
|
||||
userId: account.userId,
|
||||
keyId: account.target.keyId,
|
||||
keyPrefix: source.serviceKeyPrefix,
|
||||
fingerprint: source.fingerprint,
|
||||
target: compactTargetStatus(targetStatus),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function syncKubernetesSecret(target: Target, account: Account, source: SourceMaterial, options: Options): Record<string, unknown> {
|
||||
const namespace = account.target.namespace ?? target.namespace;
|
||||
const secretName = account.target.secretName ?? "";
|
||||
@@ -359,7 +403,7 @@ function runtimeSecretStatus(target: Target, account: Account, source: SourceMat
|
||||
async function userBillingApiKeyStatus(sql: any, account: Account, source: SourceMaterial): Promise<Record<string, unknown>> {
|
||||
if (!sql) return { checked: false, kind: account.target.kind, reason: "database_url_source_missing", valuesRedacted: true };
|
||||
const rows = await sql`
|
||||
SELECT u.id AS user_id, u.username, u.role, u.status AS user_status, COALESCE(a.balance_credits, 0) AS balance_credits, COALESCE(a.reserved_credits, 0) AS reserved_credits, COALESCE(a.plan_id, '') AS plan_id, k.id AS key_id, k.key_prefix, k.key_hash, k.scopes_json::text AS scopes_json, k.status AS key_status
|
||||
SELECT u.id AS user_id, u.email, u.username, u.display_name, u.role, u.status AS user_status, COALESCE(a.balance_credits, 0) AS balance_credits, COALESCE(a.reserved_credits, 0) AS reserved_credits, COALESCE(a.plan_id, '') AS plan_id, k.id AS key_id, k.key_prefix, k.key_hash, k.scopes_json::text AS scopes_json, k.status AS key_status
|
||||
FROM hwlab_users u
|
||||
LEFT JOIN hwlab_credit_accounts a ON a.user_id = u.id
|
||||
LEFT JOIN hwlab_api_keys k ON k.id = ${account.target.keyId ?? ""} AND k.user_id = u.id
|
||||
@@ -371,12 +415,41 @@ LIMIT 1`;
|
||||
checked: true,
|
||||
kind: account.target.kind,
|
||||
exists: true,
|
||||
user: { id: row.user_id, username: row.username, role: row.role, status: row.user_status, planId: row.plan_id, balanceCredits: Number(row.balance_credits ?? 0), reservedCredits: Number(row.reserved_credits ?? 0) },
|
||||
user: { id: row.user_id, email: row.email, username: row.username, displayName: row.display_name, role: row.role, status: row.user_status, planId: row.plan_id, balanceCredits: Number(row.balance_credits ?? 0), reservedCredits: Number(row.reserved_credits ?? 0) },
|
||||
apiKey: { exists: Boolean(row.key_id), keyId: account.target.keyId, keyPrefix: row.key_prefix || null, status: row.key_status || null, scopes: parseJsonArray(row.scopes_json), matchesSourceFingerprint: source.ok && source.sha256Hex && row.key_hash ? row.key_hash === source.sha256Hex : null },
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function userBillingAccountMatches(account: Account, targetStatus: Record<string, unknown>): boolean {
|
||||
const user = plainObject(targetStatus.user);
|
||||
const apiKey = plainObject(targetStatus.apiKey);
|
||||
return targetStatus.exists === true
|
||||
&& user !== null
|
||||
&& apiKey !== null
|
||||
&& user.id === account.userId
|
||||
&& (user.email ?? "") === (account.email ?? "")
|
||||
&& user.username === account.username
|
||||
&& user.displayName === account.displayName
|
||||
&& user.role === account.role
|
||||
&& user.status === account.status
|
||||
&& user.planId === account.planId
|
||||
&& Number(user.balanceCredits ?? 0) >= account.initialCredits
|
||||
&& apiKey.exists === true
|
||||
&& apiKey.keyId === account.target.keyId
|
||||
&& apiKey.status === "active"
|
||||
&& apiKey.matchesSourceFingerprint === true
|
||||
&& stringArraysEqual(apiKey.scopes, account.target.scopes);
|
||||
}
|
||||
|
||||
function compactTargetStatus(status: Record<string, unknown>): Record<string, unknown> {
|
||||
const target: Record<string, unknown> = { valuesRedacted: true };
|
||||
for (const key of ["checked", "kind", "namespace", "secretName", "targetKey", "exists", "byteCount", "keyPrefix", "fingerprint", "matchesSourceFingerprint", "user", "apiKey"]) {
|
||||
if (status[key] !== undefined) target[key] = status[key];
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function report(action: string, config: LoadedConfig, target: Target, options: Options, accounts: Record<string, unknown>[], database: SourceMaterial, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const blockers = [
|
||||
...(database.ok ? [] : [database.blocker]),
|
||||
@@ -513,6 +586,14 @@ function parseJsonArray(value: unknown): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function plainObject(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function stringArraysEqual(left: unknown, right: string[]): boolean {
|
||||
return Array.isArray(left) && left.length === right.length && left.every((item, index) => item === right[index]);
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user