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
11 changes: 11 additions & 0 deletions apps/frontend/src/components/cluster-topology/cluster-node-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export function ClusterNodeRow({
port: port.toString(),
tls: primaryConfig.tls,
verifyTlsCertificate: primaryConfig.verifyTlsCertificate,
caCertPath: primaryConfig.caCertPath,
endpointType: "node",
db: clusterDb,
}
Expand All @@ -109,6 +110,16 @@ export function ClusterNodeRow({
awsReplicationGroupId: primaryConfig.awsReplicationGroupId,
},
}))
} else if (primaryConfig.authType === "gcp-iam") {
// GCP IAM: tokens are minted from ambient credentials, no password needed
dispatch(connectPending({
connectionId,
connectionDetails: {
...baseDetails,
username: primaryConfig.username ?? "",
authType: "gcp-iam",
},
}))
Comment thread
rezakaramad marked this conversation as resolved.
} else if (R.isNotNil(encryptedPassword)) {
// Password already encrypted from existing cluster connection — do NOT re-encrypt
dispatch(connectPending({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const ClusterConnectionGroup = ({ clusterId, connections, highlight = "",
const handleConnectLatest = () => {
if (!lastOpenedNode) return
const { password, authType } = lastOpenedNode.connection.connectionDetails
if (authType !== "iam" && R.isNil(password) && onPasswordRequired) {
if (authType !== "iam" && authType !== "gcp-iam" && R.isNil(password) && onPasswordRequired) {
onPasswordRequired(lastOpenedNode.connectionId)
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const ConnectionEntry = ({
const handleDisconnect = () => dispatch(closeConnection({ connectionId }))
const handleConnect = () => {
const { password, authType } = connection.connectionDetails
if (authType !== "iam" && R.isNil(password) && onPasswordRequired) {
if (authType !== "iam" && authType !== "gcp-iam" && R.isNil(password) && onPasswordRequired) {
onPasswordRequired(connectionId)
return
}
Expand Down
12 changes: 11 additions & 1 deletion apps/frontend/src/components/ui/connection-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export function ConnectionModal({
<RadioGroup
className="flex gap-4"
onValueChange={(value) =>
onConnectionDetailsChange({ ...connectionDetails, authType: value as "password" | "iam" })
onConnectionDetailsChange({ ...connectionDetails, authType: value as "password" | "iam" | "gcp-iam" })
}
value={connectionDetails.authType ?? "password"}
>
Expand All @@ -180,6 +180,10 @@ export function ConnectionModal({
<RadioGroupItem id="auth-iam" value="iam" />
<Label htmlFor="auth-iam">AWS IAM</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="auth-gcp-iam" value="gcp-iam" />
<Label htmlFor="auth-gcp-iam">GCP IAM</Label>
</div>
</RadioGroup>
</div>

Expand Down Expand Up @@ -222,6 +226,12 @@ export function ConnectionModal({
/>
</div>
</>
) : connectionDetails.authType === "gcp-iam" ? (
<p className="text-sm text-muted-foreground">
Uses Application Default Credentials to mint and rotate a short-lived access
token. Authenticates as the <code>default</code> user — no username or
password required.
</p>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
Expand Down
9 changes: 5 additions & 4 deletions apps/frontend/src/state/epics/valkeyEpics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ export const connectionEpic = (store: Store) =>
select(connectPending),
filter(() => !selectIsAtConnectionLimit(store.getState())),
mergeMap(async (action) => {
const { password } = action.payload.connectionDetails
if (R.isNil(password) || action.payload.connectionDetails.authType === "iam") return action
const { password, authType } = action.payload.connectionDetails
if (R.isNil(password) || authType === "iam" || authType === "gcp-iam") return action

// Password is dispatched as plaintext if secureStorage is unavailable
const decryptedPassword = password.length > 0 && secureStorage.isAvailable() ? await secureStorage.decrypt(password) : password
Expand Down Expand Up @@ -283,7 +283,8 @@ export const autoReconnectEpic = (store: Store) =>
.filter(([, connection]) => connection.status === DISCONNECTED)
.filter(([, connection]) =>
R.isNotNil(connection.connectionDetails.password) ||
connection.connectionDetails.authType === "iam")
connection.connectionDetails.authType === "iam" ||
connection.connectionDetails.authType === "gcp-iam")
Comment thread
rezakaramad marked this conversation as resolved.

if (disconnectedConnections.length > 0) {
console.log(`Auto-reconnecting ${disconnectedConnections.length} connection(s)`)
Expand Down Expand Up @@ -316,7 +317,7 @@ export const autoResumeEpic = (store: Store) =>
.filter(([, connection]) => isAutoResumeEligible(connection))
.forEach(([connectionId, connection]) => {
const { password, authType } = connection.connectionDetails
if (authType === "iam" || (R.isNotNil(password) && R.isEmpty(password))) {
if (authType === "iam" || authType === "gcp-iam" || (R.isNotNil(password) && R.isEmpty(password))) {
if (connection.status !== DISCONNECTED) {
store.dispatch(connectPending({
connectionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface PrimaryNode {
//TODO: Add handling and UI for uploading cert
caCertPath?: string
replicas: ReplicaNode[];
authType?: "password" | "iam";
authType?: "password" | "iam" | "gcp-iam";
awsRegion?: string;
awsReplicationGroupId?: string;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface ConnectionDetails {
// JSON module availability check
jsonModuleAvailable?: boolean;
endpointType: EndpointType
authType?: "password" | "iam"
authType?: "password" | "iam" | "gcp-iam"
awsRegion?: string
awsReplicationGroupId?: string
/**
Expand Down Expand Up @@ -88,7 +88,7 @@ export const isAutoResumeEligible = (connection: ConnectionState): boolean => {
if (userDisconnected) return false

const { password, authType } = connectionDetails
if (authType === "iam" || (R.isNotNil(password) && R.isEmpty(password)))
if (authType === "iam" || authType === "gcp-iam" || (R.isNotNil(password) && R.isEmpty(password)))
return status !== DISCONNECTED
return R.isNil(password)
}
Expand Down
28 changes: 23 additions & 5 deletions apps/metrics/src/effects/monitor-stream.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,43 @@
import { Subject, timer, race, firstValueFrom, defer, of } from "rxjs"
import { exhaustMap, catchError, map } from "rxjs"
import Valkey from "iovalkey"
import { readFileSync } from "node:fs"
import { mintGcpAccessToken } from "valkey-common"
import { ElastiCacheIAMProvider } from "../utils/elasticache-iam-provider.js"

function getConnectionOptions() {
const host = process.env.VALKEY_HOST
const port = Number(process.env.VALKEY_PORT)
const username = process.env.VALKEY_USERNAME
// GCP IAM authenticates as the fixed "default" user; any other username is rejected.
const username = process.env.VALKEY_AUTH_TYPE === "gcp-iam"
? "default"
: process.env.VALKEY_USERNAME
const verifyTlsCertificate = process.env.VALKEY_VERIFY_CERT
let tls = undefined
if (process.env.VALKEY_TLS === "true") {
tls = verifyTlsCertificate === "false" ? { rejectUnauthorized: false } : {}
if (verifyTlsCertificate === "false") {
tls = { rejectUnauthorized: false }
} else if (process.env.VALKEY_CA_CERT_PATH) {
tls = { ca: readFileSync(process.env.VALKEY_CA_CERT_PATH) }
} else {
tls = {}
}
}
return { host, port, username, tls }
}

async function getPassword() {
const username = process.env.VALKEY_USERNAME
return process.env.VALKEY_AUTH_TYPE === "iam"
? await new ElastiCacheIAMProvider(username, process.env.VALKEY_REPLICATION_GROUP_ID, process.env.VALKEY_AWS_REGION).getCredentials()
: process.env.VALKEY_PASSWORD
if (process.env.VALKEY_AUTH_TYPE === "iam") {
return await new ElastiCacheIAMProvider(username, process.env.VALKEY_REPLICATION_GROUP_ID, process.env.VALKEY_AWS_REGION).getCredentials()
}
if (process.env.VALKEY_AUTH_TYPE === "gcp-iam") {
return await mintGcpAccessToken(
process.env.VALKEY_TLS === "true",
process.env.VALKEY_VERIFY_CERT !== "false",
)
}
return process.env.VALKEY_PASSWORD
}

/**
Expand Down
13 changes: 13 additions & 0 deletions apps/metrics/src/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from "node:fs"
import express from "express"
import { ORCHESTRATOR_AUTH_KEY_ENV, buildUrl } from "valkey-common"
import { registerGcpTokenRefresh, unregisterGcpTokenRefresh } from "valkey-common"
import { getConfig } from "./config.js"
import * as Streamer from "./effects/ndjson-streamer.js"
import { setupCollectors, stopCollectors } from "./init-collectors.js"
Expand Down Expand Up @@ -33,6 +34,17 @@ async function main() {
const client = await createValkeyClient(cfg)
const ownNodeId = sanitizeUrl(`${process.env.VALKEY_HOST}-${process.env.VALKEY_PORT}`)

// GCP OAuth2 tokens expire ~1h; rotate the connection password before then so

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.

Let's just reuse the common iam-token-refresh

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.

This is done now. metrics now calls registerGcpTokenRefresh / unregisterGcpTokenRefresh from valkey-common and unregisters on shutdown.

// reconnects keep authenticating. AWS IAM refreshes natively inside Glide.
if (process.env.VALKEY_AUTH_TYPE === "gcp-iam") {
registerGcpTokenRefresh(
client,
"metrics",
process.env.VALKEY_TLS === "true",
process.env.VALKEY_VERIFY_CERT !== "false",
)
}

await setupNdjsonCleaner(cfg)
await setupCollectors(client, cfg)

Expand Down Expand Up @@ -247,6 +259,7 @@ async function main() {
try {
await stopNdjsonCleaner()
await stopCollectors()
unregisterGcpTokenRefresh(client)
if (client) {
client.close()
}
Expand Down
38 changes: 27 additions & 11 deletions apps/metrics/src/valkey-client.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { GlideClient, GlideClusterClient, ServiceType, NodeDiscoveryMode } from "@valkey/valkey-glide"
import { APP_VERSION ,deploymentSuffix } from "valkey-common"
import { readFileSync } from "node:fs"
import { APP_VERSION ,deploymentSuffix, mintGcpAccessToken } from "valkey-common"

const clientInfoTag = `valkey-admin-metrics-${deploymentSuffix()}:${APP_VERSION}`

Expand All @@ -23,6 +24,8 @@ export const createValkeyClient = async (cfg = {}) => {
port: Number(process.env.VALKEY_PORT),
},
]
const useTLS = process.env.VALKEY_TLS === "true"
const verifyTlsCertificate = process.env.VALKEY_VERIFY_CERT !== "false"
const credentials =
process.env.VALKEY_AUTH_TYPE === "iam"
? {
Expand All @@ -33,23 +36,36 @@ export const createValkeyClient = async (cfg = {}) => {
region: process.env.VALKEY_AWS_REGION,
},
}
: process.env.VALKEY_PASSWORD ? {
username: process.env.VALKEY_USERNAME,
password: process.env.VALKEY_PASSWORD,
} : undefined
: process.env.VALKEY_AUTH_TYPE === "gcp-iam"
? {
// "default" is the only supported username for GCP IAM authentication
// https://docs.cloud.google.com/memorystore/docs/valkey/manage-iam-auth#error-messages
// mintGcpAccessToken rejects non-TLS / unverified transports for this bearer token.
username: "default",
password: await mintGcpAccessToken(useTLS, verifyTlsCertificate),
}
: process.env.VALKEY_PASSWORD ? {
username: process.env.VALKEY_USERNAME,
password: process.env.VALKEY_PASSWORD,
} : undefined

const useTLS = process.env.VALKEY_TLS === "true"
// Glide's TLS runs in its Rust core, so a custom CA must be passed explicitly
// via `rootCertificates` (Node's trust store / NODE_EXTRA_CA_CERTS do not apply).
const caCertPath = process.env.VALKEY_CA_CERT_PATH
const tlsAdvancedConfiguration = !useTLS
? undefined
: process.env.VALKEY_VERIFY_CERT === "false"
? { insecure: true }
: caCertPath
? { rootCertificates: readFileSync(caCertPath) }
: undefined
const sharedOptions = {
addresses,
credentials,
useTLS,
clientInfoTag,
advancedConfiguration: {
...(useTLS && process.env.VALKEY_VERIFY_CERT === "false" && {
tlsAdvancedConfiguration: {
insecure: true,
},
}),
...(tlsAdvancedConfiguration && { tlsAdvancedConfiguration }),
connectionTimeout: 30000,
},
requestTimeout: 5000,
Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@valkey/valkey-glide": "^2.5.2",
"express": "^4.21.2",
"express-rate-limit": "^8.3.1",
"google-auth-library": "^11.0.2",
"helmet": "^8.2.0",
"p-limit": "^6.1.0",
"ramda": "^0.31.3",
Expand Down
Loading
Loading