@appliance.sh/api-server 1.21.0 → 1.22.1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appliance.sh/api-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"author": "Eliot Lim",
|
|
6
6
|
"repository": "https://github.com/appliance-sh/appliance.sh",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"test:e2e": "vitest run --config vitest.e2e.config.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@appliance.sh/infra": "1.
|
|
23
|
-
"@appliance.sh/sdk": "1.
|
|
22
|
+
"@appliance.sh/infra": "1.22.1",
|
|
23
|
+
"@appliance.sh/sdk": "1.22.1",
|
|
24
24
|
"@aws-sdk/client-ecr": "^3.1005.0",
|
|
25
25
|
"@aws-sdk/client-s3": "^3.750.0",
|
|
26
26
|
"express": "^5.2.1"
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
|
-
import { timingSafeEqual } from 'crypto';
|
|
2
|
+
import { createHash, timingSafeEqual } from 'crypto';
|
|
3
3
|
import { apiKeyInput } from '@appliance.sh/sdk';
|
|
4
4
|
import { apiKeyService } from '../../services/api-key.service';
|
|
5
5
|
|
|
6
6
|
function constantTimeEqual(a: string, b: string): boolean {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
const ha = createHash('sha256').update(a).digest();
|
|
8
|
+
const hb = createHash('sha256').update(b).digest();
|
|
9
|
+
return timingSafeEqual(ha, hb);
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export const bootstrapRoutes = Router();
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { S3Client, GetObjectCommand
|
|
1
|
+
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
|
2
2
|
import { ECRClient, GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr';
|
|
3
3
|
import { applianceBaseConfig, applianceInput } from '@appliance.sh/sdk';
|
|
4
4
|
import type { ApplianceContainer, ApplianceFrameworkApp } from '@appliance.sh/sdk';
|
|
5
|
-
import {
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
6
|
import * as fs from 'node:fs';
|
|
7
7
|
import * as path from 'node:path';
|
|
8
8
|
import * as os from 'node:os';
|
|
@@ -60,7 +60,8 @@ export class BuildService {
|
|
|
60
60
|
|
|
61
61
|
try {
|
|
62
62
|
// List zip contents and validate paths before extracting
|
|
63
|
-
|
|
63
|
+
// Validate zip contents before extracting
|
|
64
|
+
const entries = execFileSync('zipinfo', ['-1', zipPath], { encoding: 'utf-8' }).trim().split('\n');
|
|
64
65
|
for (const entryPath of entries) {
|
|
65
66
|
const resolved = path.resolve(tmpDir, entryPath);
|
|
66
67
|
if (!resolved.startsWith(tmpDir + path.sep) && resolved !== tmpDir) {
|
|
@@ -68,7 +69,15 @@ export class BuildService {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
execFileSync('unzip', ['-o', '-q', zipPath, '-d', tmpDir], { stdio: 'pipe' });
|
|
73
|
+
|
|
74
|
+
// Reject symlinks after extraction
|
|
75
|
+
for (const entryPath of entries) {
|
|
76
|
+
const fullPath = path.join(tmpDir, entryPath);
|
|
77
|
+
if (fs.existsSync(fullPath) && fs.lstatSync(fullPath).isSymbolicLink()) {
|
|
78
|
+
throw new Error(`Zip contains symlink: ${entryPath}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
72
81
|
|
|
73
82
|
// Read the manifest
|
|
74
83
|
const manifestPath = path.join(tmpDir, 'appliance.json');
|
|
@@ -78,7 +87,7 @@ export class BuildService {
|
|
|
78
87
|
if (manifest.type === 'container') {
|
|
79
88
|
return this.resolveContainer(tmpDir, manifest, tag, config);
|
|
80
89
|
} else if (manifest.type === 'framework') {
|
|
81
|
-
return this.resolveFramework(
|
|
90
|
+
return this.resolveFramework(manifest, s3Key, config);
|
|
82
91
|
} else {
|
|
83
92
|
return { codeS3Key: s3Key };
|
|
84
93
|
}
|
|
@@ -87,53 +96,28 @@ export class BuildService {
|
|
|
87
96
|
}
|
|
88
97
|
}
|
|
89
98
|
|
|
90
|
-
|
|
91
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Framework builds are fully pre-processed by the CLI (dependencies installed,
|
|
101
|
+
* run.sh generated). The server just resolves Lambda-specific params from the
|
|
102
|
+
* manifest metadata and points at the original uploaded zip.
|
|
103
|
+
*/
|
|
104
|
+
private resolveFramework(
|
|
92
105
|
manifest: ApplianceFrameworkApp,
|
|
93
|
-
|
|
106
|
+
s3Key: string,
|
|
94
107
|
config: ReturnType<typeof getBaseConfig>
|
|
95
|
-
):
|
|
96
|
-
if (!config.aws.dataBucketName) throw new Error('Data bucket not configured');
|
|
97
|
-
|
|
108
|
+
): ResolvedBuild {
|
|
98
109
|
const port = manifest.port ?? 8080;
|
|
99
110
|
const framework = manifest.framework ?? 'auto';
|
|
100
|
-
const
|
|
101
|
-
const runtime = FRAMEWORK_RUNTIMES[detectedFramework] ?? FRAMEWORK_RUNTIMES['node'];
|
|
102
|
-
|
|
103
|
-
// Generate a run.sh that starts the web server
|
|
104
|
-
const startCommand = manifest.scripts?.start ?? this.defaultStartCommand(detectedFramework, tmpDir);
|
|
105
|
-
const runSh = ['#!/bin/bash', `export PORT=${port}`, `exec ${startCommand}`].join('\n');
|
|
106
|
-
fs.writeFileSync(path.join(tmpDir, 'run.sh'), runSh, { mode: 0o755 });
|
|
111
|
+
const runtime = FRAMEWORK_RUNTIMES[framework] ?? FRAMEWORK_RUNTIMES['node'];
|
|
107
112
|
|
|
108
|
-
// Repackage as a new zip (excluding the original zip and manifest-only artifacts)
|
|
109
|
-
const repackagedKey = `builds/${tag}.zip`;
|
|
110
|
-
const repackagedZip = path.join(tmpDir, 'repackaged.zip');
|
|
111
|
-
execSync(`cd "${tmpDir}" && zip -r "${repackagedZip}" . -x "appliance.zip" -x "repackaged.zip"`, { stdio: 'pipe' });
|
|
112
|
-
|
|
113
|
-
// Upload the repackaged zip
|
|
114
|
-
const s3 = new S3Client({ region: config.aws.region });
|
|
115
|
-
await s3.send(
|
|
116
|
-
new PutObjectCommand({
|
|
117
|
-
Bucket: config.aws.dataBucketName,
|
|
118
|
-
Key: repackagedKey,
|
|
119
|
-
Body: fs.readFileSync(repackagedZip),
|
|
120
|
-
ContentType: 'application/zip',
|
|
121
|
-
})
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
// Resolve the Lambda Web Adapter layer ARN and architecture for this region
|
|
125
113
|
const platform = manifest.platform;
|
|
126
114
|
const layerArn = LAMBDA_ADAPTER_LAYER[platform]?.replace('${region}', config.aws.region);
|
|
127
115
|
if (!layerArn) throw new Error(`No Lambda Web Adapter layer for platform: ${platform}`);
|
|
128
116
|
const architecture = FRAMEWORK_ARCHITECTURES[platform];
|
|
129
117
|
if (!architecture) throw new Error(`No Lambda architecture for platform: ${platform}`);
|
|
130
118
|
|
|
131
|
-
// The Web Adapter layer uses AWS_LAMBDA_EXEC_WRAPPER to intercept the
|
|
132
|
-
// Lambda runtime bootstrap. It starts run.sh (the web server) and proxies
|
|
133
|
-
// Lambda invocations to it as HTTP requests. The handler value is not
|
|
134
|
-
// called directly but must point to a valid file to pass validation.
|
|
135
119
|
return {
|
|
136
|
-
codeS3Key:
|
|
120
|
+
codeS3Key: s3Key,
|
|
137
121
|
runtime,
|
|
138
122
|
handler: 'run.sh',
|
|
139
123
|
layers: [layerArn],
|
|
@@ -145,29 +129,14 @@ export class BuildService {
|
|
|
145
129
|
};
|
|
146
130
|
}
|
|
147
131
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return 'node';
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
private defaultStartCommand(framework: string, tmpDir: string): string {
|
|
157
|
-
if (framework === 'python') {
|
|
158
|
-
return 'python app.py';
|
|
159
|
-
}
|
|
160
|
-
// Node default
|
|
161
|
-
if (fs.existsSync(path.join(tmpDir, 'package.json'))) {
|
|
162
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf-8'));
|
|
163
|
-
if (pkg.scripts?.start) return 'npm start';
|
|
164
|
-
}
|
|
165
|
-
return 'node index.js';
|
|
166
|
-
}
|
|
167
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Container builds are fully pre-processed by the CLI (Lambda Web Adapter
|
|
134
|
+
* already injected). The server just loads the image and pushes it to ECR.
|
|
135
|
+
* All subprocess calls use execFileSync (array args) to prevent shell injection.
|
|
136
|
+
*/
|
|
168
137
|
private async resolveContainer(
|
|
169
138
|
tmpDir: string,
|
|
170
|
-
|
|
139
|
+
_manifest: ApplianceContainer,
|
|
171
140
|
tag: string,
|
|
172
141
|
config: ReturnType<typeof getBaseConfig>
|
|
173
142
|
): Promise<ResolvedBuild> {
|
|
@@ -175,33 +144,17 @@ export class BuildService {
|
|
|
175
144
|
if (!ecrRepositoryUrl) throw new Error('ECR repository not configured');
|
|
176
145
|
|
|
177
146
|
const imageTarPath = path.join(tmpDir, 'image.tar');
|
|
178
|
-
if (!fs.existsSync(imageTarPath))
|
|
147
|
+
if (!fs.existsSync(imageTarPath)) {
|
|
148
|
+
const extracted = fs.readdirSync(tmpDir);
|
|
149
|
+
throw new Error(`Build missing image.tar. Extracted contents: ${extracted.join(', ')}`);
|
|
150
|
+
}
|
|
179
151
|
|
|
180
|
-
// Load the
|
|
181
|
-
const loadOutput =
|
|
182
|
-
// Output is like "Loaded image: name:tag" or "Loaded image ID: sha256:abc..."
|
|
152
|
+
// Load the pre-built Lambda-ready image
|
|
153
|
+
const loadOutput = execFileSync('docker', ['load', '-i', imageTarPath], { encoding: 'utf-8' });
|
|
183
154
|
const loadedMatch = loadOutput.match(/Loaded image(?: ID)?:\s*(.+)/);
|
|
184
155
|
if (!loadedMatch) throw new Error(`Failed to parse docker load output: ${loadOutput}`);
|
|
185
156
|
const loadedImage = loadedMatch[1].trim();
|
|
186
157
|
|
|
187
|
-
// Wrap the image with the Lambda Web Adapter so the same plain HTTP
|
|
188
|
-
// container works on both Lambda and ECS/Fargate without any changes.
|
|
189
|
-
const lambdaImageName = `${manifest.name}-lambda`;
|
|
190
|
-
const wrapperDockerfile = path.join(tmpDir, 'Dockerfile.lambda');
|
|
191
|
-
fs.writeFileSync(
|
|
192
|
-
wrapperDockerfile,
|
|
193
|
-
[
|
|
194
|
-
`FROM --platform=${manifest.platform} public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 AS adapter`,
|
|
195
|
-
`FROM ${loadedImage}`,
|
|
196
|
-
`COPY --from=adapter /lambda-adapter /opt/extensions/lambda-adapter`,
|
|
197
|
-
`ENV AWS_LWA_PORT=${manifest.port}`,
|
|
198
|
-
].join('\n')
|
|
199
|
-
);
|
|
200
|
-
execSync(
|
|
201
|
-
`docker build --platform ${manifest.platform} --provenance=false -f "${wrapperDockerfile}" -t "${lambdaImageName}" "${tmpDir}"`,
|
|
202
|
-
{ stdio: 'pipe' }
|
|
203
|
-
);
|
|
204
|
-
|
|
205
158
|
// Auth with ECR
|
|
206
159
|
const ecr = new ECRClient({ region: config.aws.region });
|
|
207
160
|
const authResult = await ecr.send(new GetAuthorizationTokenCommand({}));
|
|
@@ -212,20 +165,20 @@ export class BuildService {
|
|
|
212
165
|
|
|
213
166
|
const decoded = Buffer.from(authData.authorizationToken, 'base64').toString();
|
|
214
167
|
const [username, password] = decoded.split(':');
|
|
215
|
-
|
|
168
|
+
execFileSync('docker', ['login', '--username', username, '--password-stdin', authData.proxyEndpoint], {
|
|
216
169
|
input: password,
|
|
217
170
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
218
171
|
});
|
|
219
172
|
|
|
220
|
-
// Tag and push
|
|
173
|
+
// Tag and push
|
|
221
174
|
const remoteTag = `${ecrRepositoryUrl}:${tag}`;
|
|
222
|
-
|
|
223
|
-
|
|
175
|
+
execFileSync('docker', ['tag', loadedImage, remoteTag], { stdio: 'pipe' });
|
|
176
|
+
execFileSync('docker', ['push', remoteTag], { stdio: 'pipe' });
|
|
224
177
|
|
|
225
178
|
// Get digest
|
|
226
179
|
let imageUri: string;
|
|
227
180
|
try {
|
|
228
|
-
imageUri =
|
|
181
|
+
imageUri = execFileSync('docker', ['inspect', '--format={{index .RepoDigests 0}}', remoteTag], {
|
|
229
182
|
encoding: 'utf-8',
|
|
230
183
|
}).trim();
|
|
231
184
|
} catch {
|