Skip to content

Commit c521e13

Browse files
committed
fix(argocd): stop the redis restart from making a split worse
Two failure paths the first cut got wrong: - If argocd-redis itself fails to restart, restarting its clients hands them the new password while redis still requires the old one — exactly the WRONGPASS split this is meant to prevent. Redis now runs first and is the only target allowed to abort the sequence; a 404 means there is nothing to keep in step, anything else is raised. - `.catch(() => undefined)` on the read swallowed RBAC and transient errors, so a real rotation could silently skip the restart. getK8sSecret already maps 404 to undefined, so the catch only ever hid real failures. A failed read now assumes rotation — a redundant restart is cheap, a missed one is an outage.
1 parent 6a9dddd commit c521e13

2 files changed

Lines changed: 71 additions & 3 deletions

File tree

src/common/k8s.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,19 @@ describe('createArgoCdRedisSecret', () => {
10811081
expect(deps.restartArgoCdRedisConsumers).not.toHaveBeenCalled()
10821082
})
10831083

1084+
it('should restart when the existing secret cannot be read at all', async () => {
1085+
const deps = makeDeps({
1086+
getK8sSecret: jest.fn(async () => {
1087+
throw new Error('forbidden')
1088+
}),
1089+
})
1090+
1091+
await k8s.createArgoCdRedisSecret({ apps: { argocd: { redisPassword: password } } }, deps as any)
1092+
1093+
expect(objectApi.patch).toHaveBeenCalled()
1094+
expect(deps.restartArgoCdRedisConsumers).toHaveBeenCalledWith('argocd')
1095+
})
1096+
10841097
it('should skip reconciliation entirely when no password is supplied', async () => {
10851098
const deps = makeDeps()
10861099

@@ -1121,4 +1134,32 @@ describe('restartArgoCdRedisConsumers', () => {
11211134

11221135
expect(deps.restartDeployment).toHaveBeenCalledWith('argocd-repo-server', 'argocd')
11231136
})
1137+
1138+
it('should not restart the clients when redis itself fails to restart', async () => {
1139+
const deps = makeDeps({
1140+
restartDeployment: jest.fn(async (name: string) => {
1141+
if (name === 'argocd-redis') throw new Error('boom')
1142+
}),
1143+
})
1144+
1145+
await expect(k8s.restartArgoCdRedisConsumers('argocd', deps as any)).rejects.toThrow('boom')
1146+
1147+
expect(deps.restartDeployment).toHaveBeenCalledTimes(1)
1148+
expect(deps.restartDeployment).toHaveBeenCalledWith('argocd-redis', 'argocd')
1149+
expect(deps.restartStatefulSet).not.toHaveBeenCalled()
1150+
})
1151+
1152+
it('should leave the clients alone when redis is not present at all', async () => {
1153+
const notFound = new MockApiException(404, 'not found', {}, {})
1154+
const deps = makeDeps({
1155+
restartDeployment: jest.fn(async (name: string) => {
1156+
if (name === 'argocd-redis') throw notFound
1157+
}),
1158+
})
1159+
1160+
await expect(k8s.restartArgoCdRedisConsumers('argocd', deps as any)).resolves.toBeUndefined()
1161+
1162+
expect(deps.restartDeployment).toHaveBeenCalledTimes(1)
1163+
expect(deps.restartStatefulSet).not.toHaveBeenCalled()
1164+
})
11241165
})

src/common/k8s.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -838,7 +838,25 @@ export const restartArgoCdRedisConsumers = async (
838838
deps = { restartDeployment, restartStatefulSet },
839839
): Promise<void> => {
840840
const d = terminal('common:k8s:restartArgoCdRedisConsumers')
841-
for (const target of ARGOCD_REDIS_RESTART_TARGETS) {
841+
const [redis, ...consumers] = ARGOCD_REDIS_RESTART_TARGETS
842+
843+
// Redis has to come back on the new password before anything is pointed at it. If it does not
844+
// restart, restarting the clients is worse than doing nothing: they would pick up the new
845+
// password while redis still requires the old one, which is the split this function exists to
846+
// prevent. So redis, and only redis, is allowed to stop the sequence.
847+
try {
848+
await deps.restartDeployment(redis.name, namespace)
849+
d.info(`Restarted ${redis.kind}/${redis.name} after redis password change`)
850+
} catch (error) {
851+
if (error instanceof ApiException && error.code === 404) {
852+
d.debug(`Could not restart ${redis.kind}/${redis.name}: not found — leaving its clients alone`)
853+
return
854+
}
855+
d.error(`Could not restart ${redis.kind}/${redis.name}, not restarting its clients:`, error)
856+
throw error
857+
}
858+
859+
for (const target of consumers) {
842860
try {
843861
if (target.kind === 'deployment') await deps.restartDeployment(target.name, namespace)
844862
else await deps.restartStatefulSet(target.name, namespace)
@@ -871,8 +889,17 @@ export const createArgoCdRedisSecret = async (
871889

872890
// Read before writing so the restart below is limited to an actual rotation. This runs on every
873891
// install, and restarting the whole Argo stack on an unchanged password would be its own outage.
874-
const existingSecret = await deps.getK8sSecret(secretName, argocdNamespace).catch(() => undefined)
875-
const passwordChanged = existingSecret !== undefined && existingSecret.auth !== redisPassword
892+
// getK8sSecret already maps 404 to undefined (first install — nothing to restart), so anything
893+
// that throws here is a real read failure and leaves us unable to tell. Assume it rotated: a
894+
// redundant restart costs seconds, a missed one leaves redis and its clients split on WRONGPASS.
895+
let passwordChanged: boolean
896+
try {
897+
const existingSecret = await deps.getK8sSecret(secretName, argocdNamespace)
898+
passwordChanged = existingSecret !== undefined && existingSecret.auth !== redisPassword
899+
} catch (error) {
900+
d.warn(`Could not read Secret ${secretName} to detect a password change, assuming it rotated:`, error)
901+
passwordChanged = true
902+
}
876903

877904
try {
878905
await k8s.object().patch(

0 commit comments

Comments
 (0)