@kirschbaum-development/sst-laravel 0.2.10 → 0.2.12
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/dist/bin/commands/deploy.js +1 -43
- package/dist/bin/commands/deploy.js.map +1 -1
- package/dist/bin/commands/init.js +1 -1
- package/dist/bin/commands/init.js.map +1 -1
- package/dist/bin/utils/secrets-manager.d.ts +4 -0
- package/dist/bin/utils/secrets-manager.js +21 -0
- package/dist/bin/utils/secrets-manager.js.map +1 -1
- package/laravel-sst.ts +759 -587
- package/package.json +1 -1
- package/src/laravel-env-manager.ts +1 -1
- package/src/remote-env-file.ts +235 -0
- package/src/secrets-manager.ts +544 -0
package/package.json
CHANGED
|
@@ -62,7 +62,7 @@ export interface RemoteEnvVaultArgs {
|
|
|
62
62
|
* sst-laravel env:pull --stage production
|
|
63
63
|
*
|
|
64
64
|
* # Deploy (automatically fetches secrets)
|
|
65
|
-
* sst
|
|
65
|
+
* sst deploy --stage production
|
|
66
66
|
* ```
|
|
67
67
|
*/
|
|
68
68
|
export class RemoteEnvVault extends Component {
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { CustomResourceOptions, Input, dynamic } from '@pulumi/pulumi';
|
|
2
|
+
|
|
3
|
+
export interface RemoteEnvFileLinkedSecret {
|
|
4
|
+
name: Input<string>;
|
|
5
|
+
value: Input<string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RemoteEnvFileInputs {
|
|
9
|
+
secretPath: Input<string>;
|
|
10
|
+
envFilePath: Input<string>;
|
|
11
|
+
fingerprint: Input<string>;
|
|
12
|
+
autoInject?: Input<boolean>;
|
|
13
|
+
appUrl?: Input<string | undefined>;
|
|
14
|
+
linkedEnvironment?: Input<Record<string, Input<string | undefined> | undefined>>;
|
|
15
|
+
linkedSecrets?: Input<RemoteEnvFileLinkedSecret[]>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ResolvedRemoteEnvFileInputs {
|
|
19
|
+
secretPath: string;
|
|
20
|
+
envFilePath: string;
|
|
21
|
+
fingerprint: string;
|
|
22
|
+
autoInject?: boolean;
|
|
23
|
+
appUrl?: string;
|
|
24
|
+
linkedEnvironment?: Record<string, string | undefined>;
|
|
25
|
+
linkedSecrets?: Array<{
|
|
26
|
+
name: string;
|
|
27
|
+
value: string;
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const provider: dynamic.ResourceProvider<ResolvedRemoteEnvFileInputs, ResolvedRemoteEnvFileInputs> = {
|
|
32
|
+
async create(inputs) {
|
|
33
|
+
const outs = await writeRemoteEnvironmentFile(inputs);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
id: `${inputs.secretPath}:${inputs.envFilePath}`,
|
|
37
|
+
outs,
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async diff(_, olds, news) {
|
|
42
|
+
return {
|
|
43
|
+
changes: stableStringify(olds) !== stableStringify(news),
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async update(_, __, news) {
|
|
48
|
+
const outs = await writeRemoteEnvironmentFile(news);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
outs,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export class RemoteEnvFile extends dynamic.Resource {
|
|
57
|
+
constructor(
|
|
58
|
+
name: string,
|
|
59
|
+
args: RemoteEnvFileInputs,
|
|
60
|
+
opts?: CustomResourceOptions,
|
|
61
|
+
) {
|
|
62
|
+
super(provider, `${name}.sst.aws.RemoteEnvFile`, args, opts);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function writeRemoteEnvironmentFile(inputs: ResolvedRemoteEnvFileInputs) {
|
|
67
|
+
const fs = await import('node:fs');
|
|
68
|
+
const path = await import('node:path');
|
|
69
|
+
const secrets = await pullSecretsFromAws(inputs.secretPath);
|
|
70
|
+
|
|
71
|
+
if (!secrets) {
|
|
72
|
+
throw new Error(`RemoteEnvVault secret not found at ${inputs.secretPath}.`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const envContent = buildEnvFileContent(secrets, inputs);
|
|
76
|
+
|
|
77
|
+
fs.mkdirSync(path.dirname(inputs.envFilePath), { recursive: true });
|
|
78
|
+
fs.writeFileSync(inputs.envFilePath, envContent + '\n');
|
|
79
|
+
fs.chmodSync(inputs.envFilePath, 0o755);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...inputs,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function pullSecretsFromAws(secretPath: string): Promise<Record<string, string> | null> {
|
|
87
|
+
const secretValue = await getSecretValue(secretPath);
|
|
88
|
+
|
|
89
|
+
if (!secretValue) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const data = JSON.parse(secretValue);
|
|
94
|
+
|
|
95
|
+
if (isChunkedSecret(data)) {
|
|
96
|
+
return pullChunkedSecrets(secretPath, data.chunks);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return data;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function pullChunkedSecrets(basePath: string, chunkCount: number): Promise<Record<string, string>> {
|
|
103
|
+
const allVars: Record<string, string> = {};
|
|
104
|
+
const chunkPromises = Array.from({ length: chunkCount }, (_, i) =>
|
|
105
|
+
getSecretValue(getChunkPath(basePath, i + 1))
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const chunkValues = await Promise.all(chunkPromises);
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < chunkValues.length; i++) {
|
|
111
|
+
const chunkValue = chunkValues[i];
|
|
112
|
+
|
|
113
|
+
if (chunkValue) {
|
|
114
|
+
Object.assign(allVars, JSON.parse(chunkValue));
|
|
115
|
+
} else {
|
|
116
|
+
console.warn(`Warning: Chunk ${i + 1} not found at ${getChunkPath(basePath, i + 1)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return allVars;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function getSecretValue(secretPath: string): Promise<string | null> {
|
|
124
|
+
const { SecretsManagerClient, GetSecretValueCommand } = await import('@aws-sdk/client-secrets-manager');
|
|
125
|
+
const client = new SecretsManagerClient({});
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const response = await client.send(new GetSecretValueCommand({
|
|
129
|
+
SecretId: secretPath,
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
return response.SecretString || null;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (isResourceNotFound(error)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isChunkedSecret(data: any): data is { chunked: true; chunks: number } {
|
|
143
|
+
return data && typeof data === 'object' && data.chunked === true && typeof data.chunks === 'number';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isResourceNotFound(error: unknown): boolean {
|
|
147
|
+
return !!error && typeof error === 'object' && 'name' in error && error.name === 'ResourceNotFoundException';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getChunkPath(basePath: string, chunkIndex: number): string {
|
|
151
|
+
return `${basePath}/${chunkIndex}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildEnvFileContent(
|
|
155
|
+
secrets: Record<string, string>,
|
|
156
|
+
inputs: ResolvedRemoteEnvFileInputs,
|
|
157
|
+
) {
|
|
158
|
+
const baseEnv = toEnvFileContent(secrets);
|
|
159
|
+
|
|
160
|
+
if (inputs.autoInject === false) {
|
|
161
|
+
return baseEnv;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const autoInjected: Record<string, string> = {};
|
|
165
|
+
|
|
166
|
+
if (!hasOwnVariable(secrets, 'APP_URL') && inputs.appUrl) {
|
|
167
|
+
autoInjected.APP_URL = inputs.appUrl;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!hasOwnVariable(secrets, 'LOG_CHANNEL')) {
|
|
171
|
+
autoInjected.LOG_CHANNEL = 'stderr';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
Object.entries(inputs.linkedEnvironment || {}).forEach(([key, value]) => {
|
|
175
|
+
if (typeof value === 'string') {
|
|
176
|
+
autoInjected[key] = value;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
(inputs.linkedSecrets || []).forEach((secret) => {
|
|
181
|
+
autoInjected[secret.name] = secret.value;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (Object.keys(autoInjected).length === 0) {
|
|
185
|
+
return baseEnv;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return [
|
|
189
|
+
baseEnv,
|
|
190
|
+
'# --- SST-LARAVEL AUTO-INJECTED VARIABLES ---',
|
|
191
|
+
toEnvFileContent(autoInjected),
|
|
192
|
+
].filter(Boolean).join('\n\n');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function toEnvFileContent(vars: Record<string, string>): string {
|
|
196
|
+
const sortedKeys = Object.keys(vars).sort();
|
|
197
|
+
|
|
198
|
+
return sortedKeys
|
|
199
|
+
.map((key) => {
|
|
200
|
+
const value = vars[key];
|
|
201
|
+
|
|
202
|
+
if (value.includes(' ') || value.includes('"') || value.includes("'") || value.includes('\n')) {
|
|
203
|
+
const escaped = value.replace(/"/g, '\\"');
|
|
204
|
+
return `${key}="${escaped}"`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return `${key}=${value}`;
|
|
208
|
+
})
|
|
209
|
+
.join('\n');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function hasOwnVariable(vars: Record<string, string>, key: string) {
|
|
213
|
+
return Object.prototype.hasOwnProperty.call(vars, key);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function stableStringify(value: unknown): string {
|
|
217
|
+
return JSON.stringify(sortValue(value));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function sortValue(value: unknown): unknown {
|
|
221
|
+
if (Array.isArray(value)) {
|
|
222
|
+
return value.map(sortValue);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (value && typeof value === 'object') {
|
|
226
|
+
return Object.keys(value as Record<string, unknown>)
|
|
227
|
+
.sort()
|
|
228
|
+
.reduce((result, key) => {
|
|
229
|
+
result[key] = sortValue((value as Record<string, unknown>)[key]);
|
|
230
|
+
return result;
|
|
231
|
+
}, {} as Record<string, unknown>);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return value;
|
|
235
|
+
}
|