Skip to content

Commit 4bfa6fc

Browse files
committed
fix(scf): only re-push config when mutable fields change; clear error for immutable Handler/Runtime
The update path was ALWAYS calling UpdateFunctionConfiguration with the full config (including immutable Handler/Runtime), so every update-in-place — even a code-only change — was rejected by Tencent with InvalidParameterValue.Handler. - updateResource now diffs the mutable config fields (memorySize/timeout/ environment) between existing state and desired; UpdateFunctionConfiguration is only called when one actually changed (code-only updates skip it) - Handler/Runtime changes are a HARD error with a clear message (delete & recreate), instead of silently omitting them or hitting the opaque API error - tests: config-unchanged -> no re-push; mutable-field change -> re-push; Handler change -> clear immutable error
1 parent 7db4b0b commit 4bfa6fc

2 files changed

Lines changed: 118 additions & 10 deletions

File tree

src/stack/scfStack/scfResource.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -692,8 +692,44 @@ export const updateResource = async (
692692
const codeBase64 = readFileAsBase64(codePath);
693693
const codeHash = computeFileHash(codePath);
694694

695-
// Update configuration
696-
await client.scf.updateFunctionConfiguration(config);
695+
// Only push configuration when the mutable config fields actually changed —
696+
// Tencent's UpdateFunctionConfiguration rejects Handler/Runtime (immutable at
697+
// creation) and we don't want to re-send unchanged values. Handler/Runtime
698+
// changes are a hard error: the platform cannot apply them on update.
699+
const desiredDefinition = extractScfDefinition(config, codeHash, fn.iam);
700+
const existingDefinition = (existingState?.definition ?? {}) as Record<string, unknown>;
701+
const CONFIG_DIFF_KEYS = ['runtime', 'handler', 'memorySize', 'timeout', 'environment', 'role'];
702+
const mutableKeys = CONFIG_DIFF_KEYS.filter((k) => k !== 'runtime' && k !== 'handler');
703+
704+
if (
705+
desiredDefinition.handler &&
706+
existingDefinition.handler &&
707+
desiredDefinition.handler !== existingDefinition.handler
708+
) {
709+
throw new Error(
710+
`Handler is immutable in Tencent SCF and cannot be changed on update (${String(
711+
existingDefinition.handler,
712+
)} -> ${String(desiredDefinition.handler)}). Delete and recreate the function instead.`,
713+
);
714+
}
715+
if (
716+
desiredDefinition.runtime &&
717+
existingDefinition.runtime &&
718+
desiredDefinition.runtime !== existingDefinition.runtime
719+
) {
720+
throw new Error(
721+
`Runtime is immutable in Tencent SCF and cannot be changed on update (${String(
722+
existingDefinition.runtime,
723+
)} -> ${String(desiredDefinition.runtime)}). Delete and recreate the function instead.`,
724+
);
725+
}
726+
727+
const configChanged = mutableKeys.some(
728+
(k) => desiredDefinition[k as keyof typeof desiredDefinition] !== existingDefinition[k],
729+
);
730+
if (configChanged) {
731+
await client.scf.updateFunctionConfiguration(config);
732+
}
697733

698734
// Update code
699735
await client.scf.updateFunctionCode(fn.name, codeBase64);

tests/unit/stack/scfStack/scfResource.test.ts

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import * as scfTypes from '../../../../src/stack/scfStack/scfTypes';
88
import * as stateManager from '../../../../src/common/stateManager';
99
import * as hashUtils from '../../../../src/common/hashUtils';
1010
import { ProviderEnum } from '../../../../src/common';
11-
import { Context, StateFile, CURRENT_STATE_VERSION, ResourceTypeEnum } from '../../../../src/types';
11+
import {
12+
Context,
13+
ResourceState,
14+
StateFile,
15+
CURRENT_STATE_VERSION,
16+
ResourceTypeEnum,
17+
} from '../../../../src/types';
1218

1319
const mockScfOperations = {
1420
createFunction: jest.fn(),
@@ -1043,11 +1049,22 @@ describe('ScfResource', () => {
10431049
(mockScfOperations.updateFunctionConfiguration as jest.Mock).mockResolvedValue(undefined);
10441050
(mockScfOperations.updateFunctionCode as jest.Mock).mockResolvedValue(undefined);
10451051
(stateManager.setResource as jest.Mock).mockReturnValue(newState);
1052+
// Existing state has the SAME config fields (only codeHash differs) → the
1053+
// configuration must NOT be re-pushed, only the code.
1054+
(stateManager.getResource as jest.Mock).mockReturnValue({
1055+
mode: 'managed',
1056+
region: 'ap-guangzhou',
1057+
definition: { ...mockDefinition },
1058+
instances: [],
1059+
lastUpdated: '2025-01-01T00:00:00Z',
1060+
});
10461061

10471062
const result = await updateResource(mockContext, testFunction, initialState);
10481063

10491064
expect(scfTypes.functionToScfConfig).toHaveBeenCalledWith(testFunction);
1050-
expect(mockScfOperations.updateFunctionConfiguration).toHaveBeenCalledWith(mockConfig);
1065+
// Config is unchanged (existing definition === desired) → configuration is
1066+
// NOT re-pushed; only the code is updated.
1067+
expect(mockScfOperations.updateFunctionConfiguration).not.toHaveBeenCalled();
10511068
expect(mockScfOperations.updateFunctionCode).toHaveBeenCalledWith(
10521069
'test-function',
10531070
'base64encodedcontent',
@@ -1079,9 +1096,65 @@ describe('ScfResource', () => {
10791096
expect(result).toEqual(newState);
10801097
});
10811098

1099+
it('should re-push configuration when a mutable config field changes', async () => {
1100+
(mockScfOperations.updateFunctionConfiguration as jest.Mock).mockResolvedValue(undefined);
1101+
(mockScfOperations.updateFunctionCode as jest.Mock).mockResolvedValue(undefined);
1102+
(stateManager.setResource as jest.Mock).mockReturnValue(initialState);
1103+
// Existing config differs on a MUTABLE field (memorySize) → config re-pushed.
1104+
(stateManager.getResource as jest.Mock).mockReturnValue({
1105+
mode: 'managed',
1106+
region: 'ap-guangzhou',
1107+
definition: { ...mockDefinition, memorySize: 256 },
1108+
instances: [],
1109+
lastUpdated: '2025-01-01T00:00:00Z',
1110+
});
1111+
1112+
await updateResource(mockContext, testFunction, initialState);
1113+
1114+
expect(mockScfOperations.updateFunctionConfiguration).toHaveBeenCalledTimes(1);
1115+
expect(mockScfOperations.updateFunctionConfiguration).toHaveBeenCalledWith(
1116+
expect.objectContaining({ FunctionName: 'test-function' }),
1117+
);
1118+
// Immutable Handler/Runtime stripping happens inside scfOperations
1119+
// (covered by scfOperations.test.ts) — the resource layer passes config
1120+
// through as-is.
1121+
});
1122+
1123+
it('should throw a clear error when Handler changes on update', async () => {
1124+
(mockScfOperations.updateFunctionCode as jest.Mock).mockResolvedValue(undefined);
1125+
// Existing definition has a DIFFERENT handler.
1126+
(stateManager.getResource as jest.Mock).mockReturnValue({
1127+
mode: 'managed',
1128+
region: 'ap-guangzhou',
1129+
definition: { ...mockDefinition, handler: 'old.handler' },
1130+
instances: [],
1131+
lastUpdated: '2025-01-01T00:00:00Z',
1132+
});
1133+
const fnWithNewHandler = {
1134+
...testFunction,
1135+
code: { ...testFunction.code, handler: 'new.handler' },
1136+
};
1137+
1138+
await expect(updateResource(mockContext, fnWithNewHandler, initialState)).rejects.toThrow(
1139+
/Handler is immutable/,
1140+
);
1141+
expect(mockScfOperations.updateFunctionConfiguration).not.toHaveBeenCalled();
1142+
expect(mockScfOperations.updateFunctionCode).not.toHaveBeenCalled();
1143+
});
1144+
10821145
it('should propagate errors from updateScfFunctionConfiguration', async () => {
10831146
const error = new Error('Update config failed');
10841147
(mockScfOperations.updateFunctionConfiguration as jest.Mock).mockRejectedValue(error);
1148+
(mockScfOperations.updateFunctionCode as jest.Mock).mockResolvedValue(undefined);
1149+
// Make a mutable config field differ so the configuration is re-pushed.
1150+
const oldConfigResource: ResourceState = {
1151+
mode: 'managed',
1152+
region: 'ap-guangzhou',
1153+
definition: { ...mockDefinition, memorySize: 256 },
1154+
instances: [],
1155+
lastUpdated: '2025-01-01T00:00:00Z',
1156+
};
1157+
(stateManager.getResource as jest.Mock).mockReturnValue(oldConfigResource);
10851158

10861159
await expect(updateResource(mockContext, testFunction, initialState)).rejects.toThrow(
10871160
'Update config failed',
@@ -1169,12 +1242,11 @@ describe('ScfResource', () => {
11691242

11701243
await updateResource(mockContext, fnWithIam, stateWithRole);
11711244

1172-
// Role config should be injected into SCF config
1173-
const expectedConfig = {
1174-
...mockConfig,
1175-
Role: 'existing-role',
1176-
};
1177-
expect(mockScfOperations.updateFunctionConfiguration).toHaveBeenCalledWith(expectedConfig);
1245+
// Role config is injected into the SCF config, but since no mutable config
1246+
// field changed (memory/timeout/env identical), the configuration is not
1247+
// re-pushed — role changes are applied via updateRolePolicy/updateManagedPolicies.
1248+
expect(mockCamOperations.updateRolePolicy).toHaveBeenCalled();
1249+
expect(mockScfOperations.updateFunctionConfiguration).not.toHaveBeenCalled();
11781250
});
11791251

11801252
it('should create role during update when no existing role', async () => {

0 commit comments

Comments
 (0)