@friggframework/serverless-plugin 2.0.0-next.8 → 2.0.0-next.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +125 -109
- package/index.test.js +296 -0
- package/lib/esbuild-directory-manager.js +27 -0
- package/lib/esbuild-directory-manager.test.js +65 -0
- package/lib/localstack-queue-service.js +149 -0
- package/lib/localstack-queue-service.test.js +280 -0
- package/lib/queue-environment-mapper.js +44 -0
- package/lib/queue-environment-mapper.test.js +74 -0
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -1,130 +1,146 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { QueueEnvironmentMapper } = require('./lib/queue-environment-mapper');
|
|
2
|
+
const { LocalStackQueueService } = require('./lib/localstack-queue-service');
|
|
3
|
+
const { EsbuildDirectoryManager } = require('./lib/esbuild-directory-manager');
|
|
2
4
|
|
|
3
5
|
class FriggServerlessPlugin {
|
|
4
6
|
constructor(serverless, options) {
|
|
5
7
|
this.serverless = serverless;
|
|
6
8
|
this.options = options;
|
|
7
|
-
this.provider = serverless.getProvider(
|
|
9
|
+
this.provider = serverless.getProvider('aws');
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const basePath = serverless.config.servicePath || process.cwd();
|
|
14
|
+
|
|
15
|
+
const dirManager = new EsbuildDirectoryManager(fs, path);
|
|
16
|
+
try {
|
|
17
|
+
const esbuildDir = dirManager.ensureDirectory(basePath);
|
|
18
|
+
console.log(`✓ Frigg plugin created ${esbuildDir}`);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error(`⚠️ Failed to create esbuild directory:`, error.message);
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
this.hooks = {
|
|
9
24
|
initialize: () => this.init(),
|
|
10
|
-
|
|
11
|
-
|
|
25
|
+
'before:package:initialize': () => this.beforePackageInitialize(),
|
|
26
|
+
'after:package:package': () => this.afterPackage(),
|
|
27
|
+
'before:deploy:deploy': () => this.beforeDeploy(),
|
|
12
28
|
};
|
|
13
29
|
}
|
|
30
|
+
|
|
14
31
|
async asyncInit() {
|
|
15
|
-
this.serverless.cli.log(
|
|
16
|
-
console.log(
|
|
17
|
-
if (this.serverless.processedInput.commands.includes("offline")) {
|
|
18
|
-
console.log("Running in offline mode. Making queues!");
|
|
19
|
-
const queues = Object.keys(this.serverless.service.custom)
|
|
20
|
-
.filter((key) => key.endsWith("Queue"))
|
|
21
|
-
.map((key) => {
|
|
22
|
-
return {
|
|
23
|
-
key,
|
|
24
|
-
name: this.serverless.service.custom[key],
|
|
25
|
-
};
|
|
26
|
-
});
|
|
27
|
-
console.log("Queues to be created:", queues);
|
|
28
|
-
|
|
29
|
-
const AWS = require("aws-sdk");
|
|
30
|
-
|
|
31
|
-
const endpointUrl = "localhost:4566"; // Assuming localstack is running on port 4
|
|
32
|
-
const region = "us-east-1";
|
|
33
|
-
|
|
34
|
-
// Configure AWS SDK
|
|
35
|
-
AWS.config.update({
|
|
36
|
-
region: region,
|
|
37
|
-
endpoint: endpointUrl,
|
|
38
|
-
});
|
|
32
|
+
this.serverless.cli.log('Initializing Frigg Serverless Plugin...');
|
|
33
|
+
console.log('Hello from Frigg Serverless Plugin!');
|
|
39
34
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (typeof value === "object" && value.Ref) {
|
|
47
|
-
environmentMap[value.Ref] = key;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const queueCreationPromises = queues.map((queue) => {
|
|
52
|
-
return new Promise((resolve, reject) => {
|
|
53
|
-
const params = {
|
|
54
|
-
QueueName: queue.name,
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
sqs.createQueue(params, (err, data) => {
|
|
58
|
-
if (err) {
|
|
59
|
-
console.error(
|
|
60
|
-
`Error creating queue ${queue.name}: ${err.message}`
|
|
61
|
-
);
|
|
62
|
-
reject(err);
|
|
63
|
-
} else {
|
|
64
|
-
const queueUrl = data.QueueUrl;
|
|
65
|
-
console.log(
|
|
66
|
-
`Queue ${queue.name} created successfully. URL: ${queueUrl}`
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
const environmentKey = environmentMap[queue.key];
|
|
70
|
-
this.serverless.extendConfiguration(
|
|
71
|
-
["provider", "environment", environmentKey],
|
|
72
|
-
queueUrl
|
|
73
|
-
);
|
|
74
|
-
console.log(`Set ${environmentKey} to ${queueUrl}`);
|
|
75
|
-
resolve(queueUrl);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
});
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
const basePath = this.serverless.config.servicePath || process.cwd();
|
|
38
|
+
|
|
39
|
+
const dirManager = new EsbuildDirectoryManager(fs, path);
|
|
40
|
+
const esbuildDir = dirManager.ensureDirectory(basePath);
|
|
80
41
|
|
|
81
|
-
|
|
42
|
+
if (this.serverless.processedInput.commands.includes('offline')) {
|
|
43
|
+
console.log('Running in offline mode. Making queues!');
|
|
44
|
+
await this.setupOfflineQueues();
|
|
82
45
|
} else {
|
|
83
|
-
console.log(
|
|
46
|
+
console.log('Running in online mode, doing nothing');
|
|
84
47
|
}
|
|
85
48
|
}
|
|
86
|
-
|
|
49
|
+
|
|
50
|
+
async setupOfflineQueues() {
|
|
51
|
+
const queues = this.extractQueueDefinitions();
|
|
52
|
+
console.log('Queues to be created:', queues);
|
|
53
|
+
|
|
54
|
+
const sqsClient = this.createLocalStackSQSClient();
|
|
55
|
+
const queueService = new LocalStackQueueService(sqsClient);
|
|
56
|
+
const mapper = new QueueEnvironmentMapper();
|
|
57
|
+
|
|
58
|
+
const environmentMap = mapper.createMapping(queues);
|
|
59
|
+
const createdQueues = await queueService.createQueues(queues);
|
|
60
|
+
|
|
61
|
+
createdQueues.forEach(({ key, url }) => {
|
|
62
|
+
const envKey = mapper.getEnvironmentKey(key, environmentMap);
|
|
63
|
+
this.serverless.extendConfiguration(['provider', 'environment', envKey], url);
|
|
64
|
+
console.log(`Set ${envKey} to ${url}`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
extractQueueDefinitions() {
|
|
69
|
+
// Each custom.*Queue entry is the resolved QueueName. The matching
|
|
70
|
+
// CloudFormation resource (under resources.Resources) has the same
|
|
71
|
+
// logical ID and carries the Properties block we want to mirror onto
|
|
72
|
+
// LocalStack (VisibilityTimeout, MessageRetentionPeriod,
|
|
73
|
+
// RedrivePolicy, …). Deployed AWS applies those via CloudFormation;
|
|
74
|
+
// locally they'd be silently dropped and LocalStack would fall back
|
|
75
|
+
// to AWS defaults — notably a 30s VisibilityTimeout which
|
|
76
|
+
// re-delivers in-flight messages while a long-running queue worker
|
|
77
|
+
// is still processing them.
|
|
78
|
+
const resources =
|
|
79
|
+
this.serverless.service.resources &&
|
|
80
|
+
this.serverless.service.resources.Resources
|
|
81
|
+
? this.serverless.service.resources.Resources
|
|
82
|
+
: {};
|
|
83
|
+
|
|
84
|
+
return Object.keys(this.serverless.service.custom)
|
|
85
|
+
.filter((key) => key.endsWith('Queue'))
|
|
86
|
+
.map((key) => {
|
|
87
|
+
const resource = resources[key];
|
|
88
|
+
const properties =
|
|
89
|
+
resource &&
|
|
90
|
+
resource.Type === 'AWS::SQS::Queue' &&
|
|
91
|
+
resource.Properties
|
|
92
|
+
? resource.Properties
|
|
93
|
+
: undefined;
|
|
94
|
+
return {
|
|
95
|
+
key,
|
|
96
|
+
name: this.serverless.service.custom[key],
|
|
97
|
+
...(properties ? { properties } : {}),
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
createLocalStackSQSClient() {
|
|
103
|
+
const AWS = require('aws-sdk');
|
|
104
|
+
|
|
105
|
+
AWS.config.update({
|
|
106
|
+
region: process.env.AWS_REGION || 'us-east-1',
|
|
107
|
+
endpoint: process.env.AWS_ENDPOINT || 'http://localhost:4566',
|
|
108
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'root',
|
|
109
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'root',
|
|
110
|
+
s3ForcePathStyle: true,
|
|
111
|
+
sslEnabled: false,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return new AWS.SQS({ sslEnabled: false });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async beforePackageInitialize() {
|
|
118
|
+
this.serverless.cli.log('Frigg Serverless Plugin: Pre-package hook');
|
|
119
|
+
|
|
120
|
+
const fs = require('fs');
|
|
121
|
+
const path = require('path');
|
|
122
|
+
const basePath = this.serverless.config.servicePath || process.cwd();
|
|
123
|
+
|
|
124
|
+
const dirManager = new EsbuildDirectoryManager(fs, path);
|
|
125
|
+
dirManager.ensureDirectory(basePath);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
init() {
|
|
129
|
+
const fs = require('fs');
|
|
130
|
+
const path = require('path');
|
|
131
|
+
const basePath = this.serverless.config.servicePath || process.cwd();
|
|
132
|
+
|
|
133
|
+
const dirManager = new EsbuildDirectoryManager(fs, path);
|
|
134
|
+
const esbuildDir = dirManager.ensureDirectory(basePath);
|
|
135
|
+
console.log(`Created ${esbuildDir} directory for serverless-esbuild`);
|
|
136
|
+
}
|
|
137
|
+
|
|
87
138
|
afterPackage() {
|
|
88
|
-
console.log(
|
|
89
|
-
// // const queues = Object.keys(infrastructure.custom)
|
|
90
|
-
// // .filter((key) => key.endsWith('Queue'))
|
|
91
|
-
// // .map((key) => infrastructure.custom[key]);
|
|
92
|
-
// // console.log('Queues to be created:', queues);
|
|
93
|
-
// //
|
|
94
|
-
// // const endpointUrl = 'http://localhost:4566'; // Assuming localstack is running on port 4
|
|
95
|
-
// // const region = 'us-east-1';
|
|
96
|
-
// // const command = 'aws';
|
|
97
|
-
// // queues.forEach((queue) => {
|
|
98
|
-
// // const args = [
|
|
99
|
-
// // '--endpoint-url',
|
|
100
|
-
// // endpointUrl,
|
|
101
|
-
// // 'sqs',
|
|
102
|
-
// // 'create-queue',
|
|
103
|
-
// // '--queue-name',
|
|
104
|
-
// // queue,
|
|
105
|
-
// // '--region',
|
|
106
|
-
// // region,
|
|
107
|
-
// // '--output',
|
|
108
|
-
// // 'table',
|
|
109
|
-
// // ];
|
|
110
|
-
// //
|
|
111
|
-
// // const childProcess = spawn(command, args, {
|
|
112
|
-
// // cwd: backendPath,
|
|
113
|
-
// // stdio: 'inherit',
|
|
114
|
-
// // });
|
|
115
|
-
// // childProcess.on('error', (error) => {
|
|
116
|
-
// // console.error(`Error executing command: ${error.message}`);
|
|
117
|
-
// // });
|
|
118
|
-
// //
|
|
119
|
-
// // childProcess.on('close', (code) => {
|
|
120
|
-
// // if (code !== 0) {
|
|
121
|
-
// // console.log(`Child process exited with code ${code}`);
|
|
122
|
-
// // }
|
|
123
|
-
// // });
|
|
124
|
-
// });
|
|
139
|
+
console.log('After package hook called');
|
|
125
140
|
}
|
|
141
|
+
|
|
126
142
|
beforeDeploy() {
|
|
127
|
-
console.log(
|
|
143
|
+
console.log('Before deploy hook called');
|
|
128
144
|
}
|
|
129
145
|
}
|
|
130
146
|
|
package/index.test.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
const FriggServerlessPlugin = require('./index');
|
|
2
|
+
|
|
3
|
+
jest.mock('./lib/queue-environment-mapper');
|
|
4
|
+
jest.mock('./lib/localstack-queue-service');
|
|
5
|
+
jest.mock('./lib/esbuild-directory-manager');
|
|
6
|
+
|
|
7
|
+
const { QueueEnvironmentMapper } = require('./lib/queue-environment-mapper');
|
|
8
|
+
const { LocalStackQueueService } = require('./lib/localstack-queue-service');
|
|
9
|
+
const { EsbuildDirectoryManager } = require('./lib/esbuild-directory-manager');
|
|
10
|
+
|
|
11
|
+
describe('FriggServerlessPlugin', () => {
|
|
12
|
+
let plugin;
|
|
13
|
+
let mockServerless;
|
|
14
|
+
let mockOptions;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockServerless = {
|
|
18
|
+
config: { servicePath: '/test/path' },
|
|
19
|
+
cli: { log: jest.fn() },
|
|
20
|
+
service: {
|
|
21
|
+
custom: {},
|
|
22
|
+
provider: { environment: {} },
|
|
23
|
+
},
|
|
24
|
+
processedInput: { commands: [] },
|
|
25
|
+
getProvider: jest.fn().mockReturnValue({}),
|
|
26
|
+
extendConfiguration: jest.fn(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
mockOptions = { stage: 'test' };
|
|
30
|
+
|
|
31
|
+
EsbuildDirectoryManager.mockImplementation(() => ({
|
|
32
|
+
ensureDirectory: jest.fn().mockReturnValue('/test/path/.esbuild/.serverless'),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
jest.clearAllMocks();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('Constructor', () => {
|
|
39
|
+
it('should initialize with serverless instance and options', () => {
|
|
40
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
41
|
+
|
|
42
|
+
expect(plugin.serverless).toBe(mockServerless);
|
|
43
|
+
expect(plugin.options).toBe(mockOptions);
|
|
44
|
+
expect(plugin.hooks).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should register required hooks', () => {
|
|
48
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
49
|
+
|
|
50
|
+
expect(plugin.hooks).toHaveProperty('initialize');
|
|
51
|
+
expect(plugin.hooks).toHaveProperty('before:package:initialize');
|
|
52
|
+
expect(plugin.hooks).toHaveProperty('after:package:package');
|
|
53
|
+
expect(plugin.hooks).toHaveProperty('before:deploy:deploy');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should create esbuild directory on construction', () => {
|
|
57
|
+
const mockEnsureDir = jest.fn().mockReturnValue('/test/.esbuild/.serverless');
|
|
58
|
+
EsbuildDirectoryManager.mockImplementation(() => ({
|
|
59
|
+
ensureDirectory: mockEnsureDir,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
63
|
+
|
|
64
|
+
expect(mockEnsureDir).toHaveBeenCalledWith('/test/path');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('asyncInit', () => {
|
|
69
|
+
it('should log initialization messages', async () => {
|
|
70
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
71
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
72
|
+
|
|
73
|
+
await plugin.asyncInit();
|
|
74
|
+
|
|
75
|
+
expect(mockServerless.cli.log).toHaveBeenCalledWith('Initializing Frigg Serverless Plugin...');
|
|
76
|
+
expect(consoleLogSpy).toHaveBeenCalledWith('Hello from Frigg Serverless Plugin!');
|
|
77
|
+
|
|
78
|
+
consoleLogSpy.mockRestore();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should run in online mode when not offline', async () => {
|
|
82
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
83
|
+
mockServerless.processedInput.commands = ['deploy'];
|
|
84
|
+
|
|
85
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
86
|
+
|
|
87
|
+
await plugin.asyncInit();
|
|
88
|
+
|
|
89
|
+
expect(consoleLogSpy).toHaveBeenCalledWith('Running in online mode, doing nothing');
|
|
90
|
+
consoleLogSpy.mockRestore();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should setup offline queues when in offline mode', async () => {
|
|
94
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
95
|
+
mockServerless.processedInput.commands = ['offline'];
|
|
96
|
+
mockServerless.service.custom = { AsanaQueue: 'test-queue' };
|
|
97
|
+
|
|
98
|
+
const mockMapper = {
|
|
99
|
+
createMapping: jest.fn().mockReturnValue({ AsanaQueue: 'ASANA_QUEUE_URL' }),
|
|
100
|
+
getEnvironmentKey: jest.fn().mockReturnValue('ASANA_QUEUE_URL'),
|
|
101
|
+
};
|
|
102
|
+
QueueEnvironmentMapper.mockImplementation(() => mockMapper);
|
|
103
|
+
|
|
104
|
+
const mockQueueService = {
|
|
105
|
+
createQueues: jest.fn().mockResolvedValue([
|
|
106
|
+
{ key: 'AsanaQueue', url: 'http://localhost:4566/queue' },
|
|
107
|
+
]),
|
|
108
|
+
};
|
|
109
|
+
LocalStackQueueService.mockImplementation(() => mockQueueService);
|
|
110
|
+
|
|
111
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
112
|
+
|
|
113
|
+
await plugin.asyncInit();
|
|
114
|
+
|
|
115
|
+
expect(consoleLogSpy).toHaveBeenCalledWith('Running in offline mode. Making queues!');
|
|
116
|
+
expect(mockQueueService.createQueues).toHaveBeenCalled();
|
|
117
|
+
expect(mockServerless.extendConfiguration).toHaveBeenCalledWith(
|
|
118
|
+
['provider', 'environment', 'ASANA_QUEUE_URL'],
|
|
119
|
+
'http://localhost:4566/queue'
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
consoleLogSpy.mockRestore();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('setupOfflineQueues', () => {
|
|
127
|
+
it('should orchestrate queue creation and environment configuration', async () => {
|
|
128
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
129
|
+
mockServerless.service.custom = {
|
|
130
|
+
AsanaQueue: 'test-asana-queue',
|
|
131
|
+
SlackQueue: 'test-slack-queue',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const mockMapper = {
|
|
135
|
+
createMapping: jest.fn().mockReturnValue({
|
|
136
|
+
AsanaQueue: 'ASANA_QUEUE_URL',
|
|
137
|
+
SlackQueue: 'SLACK_QUEUE_URL',
|
|
138
|
+
}),
|
|
139
|
+
getEnvironmentKey: jest.fn()
|
|
140
|
+
.mockReturnValueOnce('ASANA_QUEUE_URL')
|
|
141
|
+
.mockReturnValueOnce('SLACK_QUEUE_URL'),
|
|
142
|
+
};
|
|
143
|
+
QueueEnvironmentMapper.mockImplementation(() => mockMapper);
|
|
144
|
+
|
|
145
|
+
const mockQueueService = {
|
|
146
|
+
createQueues: jest.fn().mockResolvedValue([
|
|
147
|
+
{ key: 'AsanaQueue', url: 'http://localhost:4566/asana' },
|
|
148
|
+
{ key: 'SlackQueue', url: 'http://localhost:4566/slack' },
|
|
149
|
+
]),
|
|
150
|
+
};
|
|
151
|
+
LocalStackQueueService.mockImplementation(() => mockQueueService);
|
|
152
|
+
|
|
153
|
+
await plugin.setupOfflineQueues();
|
|
154
|
+
|
|
155
|
+
expect(mockMapper.createMapping).toHaveBeenCalled();
|
|
156
|
+
expect(mockQueueService.createQueues).toHaveBeenCalled();
|
|
157
|
+
expect(mockServerless.extendConfiguration).toHaveBeenCalledTimes(2);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('extractQueueDefinitions', () => {
|
|
162
|
+
it('should extract queue definitions from custom config', () => {
|
|
163
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
164
|
+
mockServerless.service.custom = {
|
|
165
|
+
AsanaQueue: 'test-asana-queue',
|
|
166
|
+
someOtherConfig: 'something-else',
|
|
167
|
+
SlackQueue: 'test-slack-queue',
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const queues = plugin.extractQueueDefinitions();
|
|
171
|
+
|
|
172
|
+
expect(queues).toEqual([
|
|
173
|
+
{ key: 'AsanaQueue', name: 'test-asana-queue' },
|
|
174
|
+
{ key: 'SlackQueue', name: 'test-slack-queue' },
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should return empty array when no queues defined', () => {
|
|
179
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
180
|
+
mockServerless.service.custom = { someConfig: 'value' };
|
|
181
|
+
|
|
182
|
+
const queues = plugin.extractQueueDefinitions();
|
|
183
|
+
|
|
184
|
+
expect(queues).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should attach Properties from matching CloudFormation resources', () => {
|
|
188
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
189
|
+
mockServerless.service.custom = {
|
|
190
|
+
HubspotQueue: 'svc--dev-HubspotQueue',
|
|
191
|
+
};
|
|
192
|
+
mockServerless.service.resources = {
|
|
193
|
+
Resources: {
|
|
194
|
+
HubspotQueue: {
|
|
195
|
+
Type: 'AWS::SQS::Queue',
|
|
196
|
+
Properties: {
|
|
197
|
+
QueueName: 'svc--dev-HubspotQueue',
|
|
198
|
+
VisibilityTimeout: 1800,
|
|
199
|
+
MessageRetentionPeriod: 345600,
|
|
200
|
+
RedrivePolicy: {
|
|
201
|
+
maxReceiveCount: 3,
|
|
202
|
+
deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const queues = plugin.extractQueueDefinitions();
|
|
210
|
+
|
|
211
|
+
expect(queues).toHaveLength(1);
|
|
212
|
+
expect(queues[0]).toEqual({
|
|
213
|
+
key: 'HubspotQueue',
|
|
214
|
+
name: 'svc--dev-HubspotQueue',
|
|
215
|
+
properties: {
|
|
216
|
+
QueueName: 'svc--dev-HubspotQueue',
|
|
217
|
+
VisibilityTimeout: 1800,
|
|
218
|
+
MessageRetentionPeriod: 345600,
|
|
219
|
+
RedrivePolicy: {
|
|
220
|
+
maxReceiveCount: 3,
|
|
221
|
+
deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should omit properties when resources.Resources is absent', () => {
|
|
228
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
229
|
+
mockServerless.service.custom = { LegacyQueue: 'legacy-queue' };
|
|
230
|
+
mockServerless.service.resources = undefined;
|
|
231
|
+
|
|
232
|
+
const queues = plugin.extractQueueDefinitions();
|
|
233
|
+
|
|
234
|
+
expect(queues).toEqual([{ key: 'LegacyQueue', name: 'legacy-queue' }]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should ignore non-SQS CloudFormation resources with matching logical IDs', () => {
|
|
238
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
239
|
+
mockServerless.service.custom = { MisnamedQueue: 'misnamed-queue' };
|
|
240
|
+
mockServerless.service.resources = {
|
|
241
|
+
Resources: {
|
|
242
|
+
MisnamedQueue: {
|
|
243
|
+
Type: 'AWS::SNS::Topic',
|
|
244
|
+
Properties: { TopicName: 'not-an-sqs-queue' },
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const queues = plugin.extractQueueDefinitions();
|
|
250
|
+
|
|
251
|
+
expect(queues).toEqual([
|
|
252
|
+
{ key: 'MisnamedQueue', name: 'misnamed-queue' },
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('Hooks', () => {
|
|
258
|
+
it('should execute init hook', () => {
|
|
259
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
260
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
261
|
+
|
|
262
|
+
plugin.init();
|
|
263
|
+
|
|
264
|
+
expect(consoleLogSpy).toHaveBeenCalled();
|
|
265
|
+
consoleLogSpy.mockRestore();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should execute beforePackageInitialize hook', async () => {
|
|
269
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
270
|
+
|
|
271
|
+
await plugin.beforePackageInitialize();
|
|
272
|
+
|
|
273
|
+
expect(mockServerless.cli.log).toHaveBeenCalledWith('Frigg Serverless Plugin: Pre-package hook');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should execute afterPackage hook', () => {
|
|
277
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
278
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
279
|
+
|
|
280
|
+
plugin.afterPackage();
|
|
281
|
+
|
|
282
|
+
expect(consoleLogSpy).toHaveBeenCalledWith('After package hook called');
|
|
283
|
+
consoleLogSpy.mockRestore();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should execute beforeDeploy hook', () => {
|
|
287
|
+
plugin = new FriggServerlessPlugin(mockServerless, mockOptions);
|
|
288
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
289
|
+
|
|
290
|
+
plugin.beforeDeploy();
|
|
291
|
+
|
|
292
|
+
expect(consoleLogSpy).toHaveBeenCalledWith('Before deploy hook called');
|
|
293
|
+
consoleLogSpy.mockRestore();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure Service - ESBuild Directory Management
|
|
3
|
+
*
|
|
4
|
+
* Ensures .esbuild/.serverless directory exists to prevent ENOENT errors.
|
|
5
|
+
*/
|
|
6
|
+
class EsbuildDirectoryManager {
|
|
7
|
+
constructor(fs, path) {
|
|
8
|
+
this.fs = fs;
|
|
9
|
+
this.path = path;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} basePath - Base service path
|
|
14
|
+
* @returns {string} Created directory path
|
|
15
|
+
*/
|
|
16
|
+
ensureDirectory(basePath) {
|
|
17
|
+
const esbuildDir = this.path.join(basePath, '.esbuild', '.serverless');
|
|
18
|
+
|
|
19
|
+
if (!this.fs.existsSync(esbuildDir)) {
|
|
20
|
+
this.fs.mkdirSync(esbuildDir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return esbuildDir;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { EsbuildDirectoryManager };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const { EsbuildDirectoryManager } = require('./esbuild-directory-manager');
|
|
2
|
+
|
|
3
|
+
describe('EsbuildDirectoryManager', () => {
|
|
4
|
+
let manager;
|
|
5
|
+
let mockFs;
|
|
6
|
+
let mockPath;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockFs = {
|
|
10
|
+
existsSync: jest.fn(),
|
|
11
|
+
mkdirSync: jest.fn(),
|
|
12
|
+
};
|
|
13
|
+
mockPath = {
|
|
14
|
+
join: jest.fn((...args) => args.join('/')),
|
|
15
|
+
};
|
|
16
|
+
manager = new EsbuildDirectoryManager(mockFs, mockPath);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('ensureDirectory', () => {
|
|
20
|
+
it('should create directory if it does not exist', () => {
|
|
21
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
22
|
+
|
|
23
|
+
const result = manager.ensureDirectory('/test/path');
|
|
24
|
+
|
|
25
|
+
expect(mockPath.join).toHaveBeenCalledWith('/test/path', '.esbuild', '.serverless');
|
|
26
|
+
expect(mockFs.existsSync).toHaveBeenCalledWith('/test/path/.esbuild/.serverless');
|
|
27
|
+
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/test/path/.esbuild/.serverless', {
|
|
28
|
+
recursive: true,
|
|
29
|
+
});
|
|
30
|
+
expect(result).toBe('/test/path/.esbuild/.serverless');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should not create directory if it already exists', () => {
|
|
34
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
35
|
+
|
|
36
|
+
const result = manager.ensureDirectory('/test/path');
|
|
37
|
+
|
|
38
|
+
expect(mockFs.existsSync).toHaveBeenCalledWith('/test/path/.esbuild/.serverless');
|
|
39
|
+
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
|
|
40
|
+
expect(result).toBe('/test/path/.esbuild/.serverless');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should handle different base paths', () => {
|
|
44
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
45
|
+
|
|
46
|
+
manager.ensureDirectory('/different/base');
|
|
47
|
+
|
|
48
|
+
expect(mockPath.join).toHaveBeenCalledWith('/different/base', '.esbuild', '.serverless');
|
|
49
|
+
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/different/base/.esbuild/.serverless', {
|
|
50
|
+
recursive: true,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should propagate fs errors', () => {
|
|
55
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
56
|
+
mockFs.mkdirSync.mockImplementation(() => {
|
|
57
|
+
throw new Error('Permission denied');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(() => {
|
|
61
|
+
manager.ensureDirectory('/test/path');
|
|
62
|
+
}).toThrow('Permission denied');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure Service - LocalStack Queue Management
|
|
3
|
+
*
|
|
4
|
+
* Handles SQS queue creation in LocalStack for offline development.
|
|
5
|
+
*
|
|
6
|
+
* On deployed AWS, CloudFormation applies queue properties
|
|
7
|
+
* (VisibilityTimeout, MessageRetentionPeriod, RedrivePolicy) from the
|
|
8
|
+
* `Resources` block in serverless.yml. LocalStack gets those same
|
|
9
|
+
* properties here so local emulation matches production behavior —
|
|
10
|
+
* otherwise the queue defaults to a 30s VisibilityTimeout which re-
|
|
11
|
+
* delivers in-flight messages while a long-running queue worker is
|
|
12
|
+
* still processing them.
|
|
13
|
+
*/
|
|
14
|
+
class LocalStackQueueService {
|
|
15
|
+
constructor(sqsClient) {
|
|
16
|
+
this.sqs = sqsClient;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whitelist of CloudFormation `AWS::SQS::Queue` Properties that map
|
|
21
|
+
* onto SQS `CreateQueue` Attributes. Everything else (tags, inline
|
|
22
|
+
* refs, etc.) is dropped on the way to LocalStack.
|
|
23
|
+
* @private
|
|
24
|
+
*/
|
|
25
|
+
static PROPERTY_ATTRIBUTE_KEYS = [
|
|
26
|
+
'DelaySeconds',
|
|
27
|
+
'MaximumMessageSize',
|
|
28
|
+
'MessageRetentionPeriod',
|
|
29
|
+
'ReceiveMessageWaitTimeSeconds',
|
|
30
|
+
'VisibilityTimeout',
|
|
31
|
+
'RedrivePolicy',
|
|
32
|
+
'RedriveAllowPolicy',
|
|
33
|
+
'KmsMasterKeyId',
|
|
34
|
+
'KmsDataKeyReusePeriodSeconds',
|
|
35
|
+
'SqsManagedSseEnabled',
|
|
36
|
+
'FifoQueue',
|
|
37
|
+
'ContentBasedDeduplication',
|
|
38
|
+
'DeduplicationScope',
|
|
39
|
+
'FifoThroughputLimit',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Serialize a CloudFormation `Properties` object into the
|
|
44
|
+
* `Attributes` shape the SQS `CreateQueue` API accepts (string
|
|
45
|
+
* values only; object values like `RedrivePolicy` get JSON-encoded).
|
|
46
|
+
*
|
|
47
|
+
* Attributes whose value still contains an unresolved CloudFormation
|
|
48
|
+
* intrinsic (`Fn::GetAtt`, `Ref`, `Fn::Sub`, …) are DROPPED rather
|
|
49
|
+
* than stringified. Example: integration-builder.js emits
|
|
50
|
+
* `RedrivePolicy.deadLetterTargetArn: {'Fn::GetAtt': [...]}`, which
|
|
51
|
+
* CloudFormation resolves to a real ARN in AWS but is still a raw
|
|
52
|
+
* intrinsic object at local plugin-time. Forwarding that JSON blob
|
|
53
|
+
* to SQS `CreateQueue` would fail (`deadLetterTargetArn` must be a
|
|
54
|
+
* valid ARN string) or produce malformed config. Dropping the
|
|
55
|
+
* attribute gives local parity on every other queue property
|
|
56
|
+
* (notably `VisibilityTimeout`, which is the main reason this code
|
|
57
|
+
* exists) while leaving the DLQ association intentionally un-wired
|
|
58
|
+
* locally — matching the pre-PR behavior for that one attribute.
|
|
59
|
+
* @private
|
|
60
|
+
*/
|
|
61
|
+
_propertiesToAttributes(properties = {}) {
|
|
62
|
+
const attributes = {};
|
|
63
|
+
for (const key of LocalStackQueueService.PROPERTY_ATTRIBUTE_KEYS) {
|
|
64
|
+
const value = properties[key];
|
|
65
|
+
if (value === undefined || value === null) continue;
|
|
66
|
+
if (LocalStackQueueService._containsUnresolvedIntrinsic(value)) {
|
|
67
|
+
console.warn(
|
|
68
|
+
`[frigg-plugin] Skipping queue attribute "${key}" because it contains an unresolved CloudFormation intrinsic. ` +
|
|
69
|
+
`Deployed AWS will apply it via CloudFormation; local emulation will fall back to the AWS default for this attribute.`
|
|
70
|
+
);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
attributes[key] =
|
|
74
|
+
typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
75
|
+
}
|
|
76
|
+
return attributes;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Recursively checks whether a value still contains a CloudFormation
|
|
81
|
+
* intrinsic function key (`Fn::*` or `Ref`). Such values are unsafe
|
|
82
|
+
* to pass through to SQS `CreateQueue` — AWS's runtime API doesn't
|
|
83
|
+
* understand CloudFormation intrinsics; they're only valid inside
|
|
84
|
+
* serverless.yml / CloudFormation templates.
|
|
85
|
+
* @private
|
|
86
|
+
*/
|
|
87
|
+
static _containsUnresolvedIntrinsic(value) {
|
|
88
|
+
if (value === null || value === undefined) return false;
|
|
89
|
+
if (typeof value !== 'object') return false;
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
return value.some((v) =>
|
|
92
|
+
LocalStackQueueService._containsUnresolvedIntrinsic(v)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
for (const key of Object.keys(value)) {
|
|
96
|
+
if (key === 'Ref' || key.startsWith('Fn::')) return true;
|
|
97
|
+
if (
|
|
98
|
+
LocalStackQueueService._containsUnresolvedIntrinsic(value[key])
|
|
99
|
+
) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {string} queueName - Name of queue to create
|
|
108
|
+
* @param {Object} [attributes] - SQS CreateQueue Attributes (already
|
|
109
|
+
* stringified); missing/empty attributes fall back to AWS defaults.
|
|
110
|
+
* @returns {Promise<string>} Queue URL
|
|
111
|
+
*/
|
|
112
|
+
async createQueue(queueName, attributes) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const params = { QueueName: queueName };
|
|
115
|
+
if (attributes && Object.keys(attributes).length > 0) {
|
|
116
|
+
params.Attributes = attributes;
|
|
117
|
+
}
|
|
118
|
+
this.sqs.createQueue(params, (err, data) => {
|
|
119
|
+
if (err) {
|
|
120
|
+
reject(new Error(`Failed to create queue ${queueName}: ${err.message}`));
|
|
121
|
+
} else {
|
|
122
|
+
resolve(data.QueueUrl);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {Array<{key: string, name: string, properties?: Object}>} queues
|
|
130
|
+
* Queue definitions. `properties` mirrors the CloudFormation
|
|
131
|
+
* `AWS::SQS::Queue` Properties block; extracted by the plugin from
|
|
132
|
+
* `serverless.service.resources.Resources`.
|
|
133
|
+
* @returns {Promise<Array<{key: string, url: string}>>} Created queues with URLs
|
|
134
|
+
*/
|
|
135
|
+
async createQueues(queues) {
|
|
136
|
+
const results = await Promise.all(
|
|
137
|
+
queues.map(async (queue) => {
|
|
138
|
+
const attributes = this._propertiesToAttributes(queue.properties);
|
|
139
|
+
const url = await this.createQueue(queue.name, attributes);
|
|
140
|
+
console.log(`Queue ${queue.name} created successfully. URL: ${url}`);
|
|
141
|
+
return { key: queue.key, url };
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return results;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { LocalStackQueueService };
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
const { LocalStackQueueService } = require('./localstack-queue-service');
|
|
2
|
+
|
|
3
|
+
describe('LocalStackQueueService', () => {
|
|
4
|
+
let service;
|
|
5
|
+
let mockSQS;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockSQS = {
|
|
9
|
+
createQueue: jest.fn(),
|
|
10
|
+
};
|
|
11
|
+
service = new LocalStackQueueService(mockSQS);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('createQueue', () => {
|
|
15
|
+
it('should create queue and return URL on success', async () => {
|
|
16
|
+
const queueUrl = 'http://localhost:4566/000000000000/test-queue';
|
|
17
|
+
mockSQS.createQueue.mockImplementation((params, callback) => {
|
|
18
|
+
callback(null, { QueueUrl: queueUrl });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const result = await service.createQueue('test-queue');
|
|
22
|
+
|
|
23
|
+
expect(result).toBe(queueUrl);
|
|
24
|
+
expect(mockSQS.createQueue).toHaveBeenCalledWith(
|
|
25
|
+
{ QueueName: 'test-queue' },
|
|
26
|
+
expect.any(Function)
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should pass Attributes when provided', async () => {
|
|
31
|
+
mockSQS.createQueue.mockImplementation((params, callback) => {
|
|
32
|
+
callback(null, { QueueUrl: 'url' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await service.createQueue('test-queue', {
|
|
36
|
+
VisibilityTimeout: '1800',
|
|
37
|
+
MessageRetentionPeriod: '345600',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(mockSQS.createQueue).toHaveBeenCalledWith(
|
|
41
|
+
{
|
|
42
|
+
QueueName: 'test-queue',
|
|
43
|
+
Attributes: {
|
|
44
|
+
VisibilityTimeout: '1800',
|
|
45
|
+
MessageRetentionPeriod: '345600',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
expect.any(Function)
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should omit Attributes when empty', async () => {
|
|
53
|
+
mockSQS.createQueue.mockImplementation((params, callback) => {
|
|
54
|
+
callback(null, { QueueUrl: 'url' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await service.createQueue('test-queue', {});
|
|
58
|
+
|
|
59
|
+
expect(mockSQS.createQueue).toHaveBeenCalledWith(
|
|
60
|
+
{ QueueName: 'test-queue' },
|
|
61
|
+
expect.any(Function)
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should reject with error on failure', async () => {
|
|
66
|
+
const error = new Error('SQS Error');
|
|
67
|
+
mockSQS.createQueue.mockImplementation((params, callback) => {
|
|
68
|
+
callback(error);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await expect(service.createQueue('test-queue')).rejects.toThrow(
|
|
72
|
+
'Failed to create queue test-queue: SQS Error'
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('_propertiesToAttributes', () => {
|
|
78
|
+
it('maps CloudFormation Properties onto SQS Attributes and stringifies', () => {
|
|
79
|
+
const attrs = service._propertiesToAttributes({
|
|
80
|
+
VisibilityTimeout: 1800,
|
|
81
|
+
MessageRetentionPeriod: 345600,
|
|
82
|
+
RedrivePolicy: {
|
|
83
|
+
maxReceiveCount: 3,
|
|
84
|
+
deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
|
|
85
|
+
},
|
|
86
|
+
QueueName: 'should-be-dropped',
|
|
87
|
+
Tags: [{ Key: 'ignored', Value: 'yes' }],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(attrs).toEqual({
|
|
91
|
+
VisibilityTimeout: '1800',
|
|
92
|
+
MessageRetentionPeriod: '345600',
|
|
93
|
+
RedrivePolicy: JSON.stringify({
|
|
94
|
+
maxReceiveCount: 3,
|
|
95
|
+
deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('skips undefined and null values', () => {
|
|
101
|
+
const attrs = service._propertiesToAttributes({
|
|
102
|
+
VisibilityTimeout: 60,
|
|
103
|
+
MessageRetentionPeriod: undefined,
|
|
104
|
+
KmsMasterKeyId: null,
|
|
105
|
+
});
|
|
106
|
+
expect(attrs).toEqual({ VisibilityTimeout: '60' });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns an empty object when properties are missing', () => {
|
|
110
|
+
expect(service._propertiesToAttributes()).toEqual({});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('drops attributes containing unresolved CloudFormation intrinsics', () => {
|
|
114
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
115
|
+
const attrs = service._propertiesToAttributes({
|
|
116
|
+
VisibilityTimeout: 1800,
|
|
117
|
+
RedrivePolicy: {
|
|
118
|
+
maxReceiveCount: 3,
|
|
119
|
+
deadLetterTargetArn: {
|
|
120
|
+
'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// VisibilityTimeout survives; RedrivePolicy is dropped because
|
|
126
|
+
// deadLetterTargetArn is still an unresolved Fn::GetAtt intrinsic
|
|
127
|
+
// (real AWS resolves it via CloudFormation; LocalStack cannot).
|
|
128
|
+
expect(attrs).toEqual({ VisibilityTimeout: '1800' });
|
|
129
|
+
expect(attrs).not.toHaveProperty('RedrivePolicy');
|
|
130
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
131
|
+
expect.stringContaining('Skipping queue attribute "RedrivePolicy"')
|
|
132
|
+
);
|
|
133
|
+
warnSpy.mockRestore();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('drops attributes with Ref intrinsics', () => {
|
|
137
|
+
jest.spyOn(console, 'warn').mockImplementation();
|
|
138
|
+
const attrs = service._propertiesToAttributes({
|
|
139
|
+
VisibilityTimeout: 60,
|
|
140
|
+
KmsMasterKeyId: { Ref: 'MyKmsKey' },
|
|
141
|
+
});
|
|
142
|
+
expect(attrs).toEqual({ VisibilityTimeout: '60' });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('detects intrinsics nested deep inside objects and arrays', () => {
|
|
146
|
+
expect(
|
|
147
|
+
LocalStackQueueService._containsUnresolvedIntrinsic({
|
|
148
|
+
a: { b: [{ c: { 'Fn::Sub': '${AWS::Region}' } }] },
|
|
149
|
+
})
|
|
150
|
+
).toBe(true);
|
|
151
|
+
expect(
|
|
152
|
+
LocalStackQueueService._containsUnresolvedIntrinsic({
|
|
153
|
+
a: { b: [{ c: 'hello' }] },
|
|
154
|
+
})
|
|
155
|
+
).toBe(false);
|
|
156
|
+
expect(LocalStackQueueService._containsUnresolvedIntrinsic(null)).toBe(false);
|
|
157
|
+
expect(LocalStackQueueService._containsUnresolvedIntrinsic('str')).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('retains resolved RedrivePolicy (ARN already a string)', () => {
|
|
161
|
+
const attrs = service._propertiesToAttributes({
|
|
162
|
+
RedrivePolicy: {
|
|
163
|
+
maxReceiveCount: 3,
|
|
164
|
+
deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
expect(attrs.RedrivePolicy).toBe(
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
maxReceiveCount: 3,
|
|
170
|
+
deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('createQueues', () => {
|
|
177
|
+
it('should create multiple queues and return results', async () => {
|
|
178
|
+
mockSQS.createQueue.mockImplementation((params, callback) => {
|
|
179
|
+
const url = `http://localhost:4566/000000000000/${params.QueueName}`;
|
|
180
|
+
callback(null, { QueueUrl: url });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
184
|
+
|
|
185
|
+
const queues = [
|
|
186
|
+
{ key: 'AsanaQueue', name: 'test-asana-queue' },
|
|
187
|
+
{ key: 'SlackQueue', name: 'test-slack-queue' },
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
const results = await service.createQueues(queues);
|
|
191
|
+
|
|
192
|
+
expect(results).toHaveLength(2);
|
|
193
|
+
expect(results[0]).toEqual({
|
|
194
|
+
key: 'AsanaQueue',
|
|
195
|
+
url: 'http://localhost:4566/000000000000/test-asana-queue',
|
|
196
|
+
});
|
|
197
|
+
expect(results[1]).toEqual({
|
|
198
|
+
key: 'SlackQueue',
|
|
199
|
+
url: 'http://localhost:4566/000000000000/test-slack-queue',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(consoleLogSpy).toHaveBeenCalledTimes(2);
|
|
203
|
+
consoleLogSpy.mockRestore();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('forwards CloudFormation Properties as SQS Attributes', async () => {
|
|
207
|
+
const captured = [];
|
|
208
|
+
mockSQS.createQueue.mockImplementation((params, callback) => {
|
|
209
|
+
captured.push(params);
|
|
210
|
+
callback(null, { QueueUrl: 'url' });
|
|
211
|
+
});
|
|
212
|
+
jest.spyOn(console, 'log').mockImplementation();
|
|
213
|
+
|
|
214
|
+
await service.createQueues([
|
|
215
|
+
{
|
|
216
|
+
key: 'HubspotQueue',
|
|
217
|
+
name: 'my-service--dev-HubspotQueue',
|
|
218
|
+
properties: {
|
|
219
|
+
QueueName: 'my-service--dev-HubspotQueue',
|
|
220
|
+
VisibilityTimeout: 1800,
|
|
221
|
+
MessageRetentionPeriod: 345600,
|
|
222
|
+
RedrivePolicy: {
|
|
223
|
+
maxReceiveCount: 3,
|
|
224
|
+
deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
expect(captured[0]).toEqual({
|
|
231
|
+
QueueName: 'my-service--dev-HubspotQueue',
|
|
232
|
+
Attributes: {
|
|
233
|
+
VisibilityTimeout: '1800',
|
|
234
|
+
MessageRetentionPeriod: '345600',
|
|
235
|
+
RedrivePolicy: JSON.stringify({
|
|
236
|
+
maxReceiveCount: 3,
|
|
237
|
+
deadLetterTargetArn: 'arn:aws:sqs:us-east-1:x:dlq',
|
|
238
|
+
}),
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('creates queues with defaults when properties are missing (back-compat)', async () => {
|
|
244
|
+
const captured = [];
|
|
245
|
+
mockSQS.createQueue.mockImplementation((params, callback) => {
|
|
246
|
+
captured.push(params);
|
|
247
|
+
callback(null, { QueueUrl: 'url' });
|
|
248
|
+
});
|
|
249
|
+
jest.spyOn(console, 'log').mockImplementation();
|
|
250
|
+
|
|
251
|
+
await service.createQueues([
|
|
252
|
+
{ key: 'LegacyQueue', name: 'legacy-queue' },
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
expect(captured[0]).toEqual({ QueueName: 'legacy-queue' });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should handle empty queue array', async () => {
|
|
259
|
+
const results = await service.createQueues([]);
|
|
260
|
+
expect(results).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should reject if any queue creation fails', async () => {
|
|
264
|
+
mockSQS.createQueue.mockImplementation((params, callback) => {
|
|
265
|
+
if (params.QueueName === 'failing-queue') {
|
|
266
|
+
callback(new Error('Failed'));
|
|
267
|
+
} else {
|
|
268
|
+
callback(null, { QueueUrl: 'http://localhost:4566/queue' });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const queues = [
|
|
273
|
+
{ key: 'SuccessQueue', name: 'success-queue' },
|
|
274
|
+
{ key: 'FailQueue', name: 'failing-queue' },
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
await expect(service.createQueues(queues)).rejects.toThrow('Failed to create queue failing-queue');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain Service - Queue to Environment Variable Mapper
|
|
3
|
+
*
|
|
4
|
+
* Maps queue keys to environment variable names using naming convention.
|
|
5
|
+
* Pattern: AsanaQueue -> ASANA_QUEUE_URL
|
|
6
|
+
*/
|
|
7
|
+
class QueueEnvironmentMapper {
|
|
8
|
+
/**
|
|
9
|
+
* @param {Array<{key: string, name: string}>} queues - Queue definitions
|
|
10
|
+
* @returns {Object} Map of queue keys to environment variable names
|
|
11
|
+
*/
|
|
12
|
+
createMapping(queues) {
|
|
13
|
+
const mapping = {};
|
|
14
|
+
|
|
15
|
+
queues.forEach(queue => {
|
|
16
|
+
const baseName = queue.key.replace(/Queue$/, '');
|
|
17
|
+
const envVarName = `${baseName.toUpperCase()}_QUEUE_URL`;
|
|
18
|
+
mapping[queue.key] = envVarName;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return mapping;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} queueKey - Queue key to look up
|
|
26
|
+
* @param {Object} mapping - Environment variable mapping
|
|
27
|
+
* @returns {string} Environment variable name
|
|
28
|
+
* @throws {Error} If no mapping found
|
|
29
|
+
*/
|
|
30
|
+
getEnvironmentKey(queueKey, mapping) {
|
|
31
|
+
const envKey = mapping[queueKey];
|
|
32
|
+
|
|
33
|
+
if (!envKey) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`No environment variable mapping found for queue "${queueKey}". ` +
|
|
36
|
+
`Expected pattern: {QueueName}Queue -> {QUEUENAME}_QUEUE_URL`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return envKey;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { QueueEnvironmentMapper };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const { QueueEnvironmentMapper } = require('./queue-environment-mapper');
|
|
2
|
+
|
|
3
|
+
describe('QueueEnvironmentMapper', () => {
|
|
4
|
+
let mapper;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
mapper = new QueueEnvironmentMapper();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('createMapping', () => {
|
|
11
|
+
it('should map single queue key to environment variable name', () => {
|
|
12
|
+
const queues = [{ key: 'AsanaQueue', name: 'test-asana-queue' }];
|
|
13
|
+
const result = mapper.createMapping(queues);
|
|
14
|
+
|
|
15
|
+
expect(result).toEqual({
|
|
16
|
+
AsanaQueue: 'ASANA_QUEUE_URL',
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should map multiple queue keys to environment variable names', () => {
|
|
21
|
+
const queues = [
|
|
22
|
+
{ key: 'AsanaQueue', name: 'test-asana-queue' },
|
|
23
|
+
{ key: 'SlackQueue', name: 'test-slack-queue' },
|
|
24
|
+
{ key: 'HubspotQueue', name: 'test-hubspot-queue' },
|
|
25
|
+
];
|
|
26
|
+
const result = mapper.createMapping(queues);
|
|
27
|
+
|
|
28
|
+
expect(result).toEqual({
|
|
29
|
+
AsanaQueue: 'ASANA_QUEUE_URL',
|
|
30
|
+
SlackQueue: 'SLACK_QUEUE_URL',
|
|
31
|
+
HubspotQueue: 'HUBSPOT_QUEUE_URL',
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should handle empty queue array', () => {
|
|
36
|
+
const result = mapper.createMapping([]);
|
|
37
|
+
expect(result).toEqual({});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should preserve case in base name conversion', () => {
|
|
41
|
+
const queues = [{ key: 'MyCustomQueue', name: 'test-queue' }];
|
|
42
|
+
const result = mapper.createMapping(queues);
|
|
43
|
+
|
|
44
|
+
expect(result).toEqual({
|
|
45
|
+
MyCustomQueue: 'MYCUSTOM_QUEUE_URL',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('getEnvironmentKey', () => {
|
|
51
|
+
it('should return environment variable name for valid queue key', () => {
|
|
52
|
+
const mapping = { AsanaQueue: 'ASANA_QUEUE_URL' };
|
|
53
|
+
const result = mapper.getEnvironmentKey('AsanaQueue', mapping);
|
|
54
|
+
|
|
55
|
+
expect(result).toBe('ASANA_QUEUE_URL');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should throw error for missing queue key', () => {
|
|
59
|
+
const mapping = { AsanaQueue: 'ASANA_QUEUE_URL' };
|
|
60
|
+
|
|
61
|
+
expect(() => {
|
|
62
|
+
mapper.getEnvironmentKey('InvalidQueue', mapping);
|
|
63
|
+
}).toThrow('No environment variable mapping found for queue "InvalidQueue"');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should throw error with helpful message pattern', () => {
|
|
67
|
+
const mapping = {};
|
|
68
|
+
|
|
69
|
+
expect(() => {
|
|
70
|
+
mapper.getEnvironmentKey('TestQueue', mapping);
|
|
71
|
+
}).toThrow('Expected pattern: {QueueName}Queue -> {QUEUENAME}_QUEUE_URL');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/serverless-plugin",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.81",
|
|
4
4
|
"description": "Plugin to help dynamically load frigg resources",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -11,5 +11,5 @@
|
|
|
11
11
|
"publishConfig": {
|
|
12
12
|
"access": "public"
|
|
13
13
|
},
|
|
14
|
-
"gitHead": "
|
|
14
|
+
"gitHead": "f928679326fe06cc56ac46e97cf268fe8f8e823e"
|
|
15
15
|
}
|