@appliance.sh/api-server 1.19.1 → 1.21.0

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.19.1",
3
+ "version": "1.21.0",
4
4
  "description": "",
5
5
  "author": "Eliot Lim",
6
6
  "repository": "https://github.com/appliance-sh/appliance.sh",
@@ -19,8 +19,9 @@
19
19
  "test:e2e": "vitest run --config vitest.e2e.config.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@appliance.sh/infra": "1.19.1",
23
- "@appliance.sh/sdk": "1.19.1",
22
+ "@appliance.sh/infra": "1.21.0",
23
+ "@appliance.sh/sdk": "1.21.0",
24
+ "@aws-sdk/client-ecr": "^3.1005.0",
24
25
  "@aws-sdk/client-s3": "^3.750.0",
25
26
  "express": "^5.2.1"
26
27
  },
package/src/main.ts CHANGED
@@ -4,6 +4,7 @@ import { indexRoutes } from './routes';
4
4
  import { projectRoutes } from './routes/projects';
5
5
  import { environmentRoutes } from './routes/environments';
6
6
  import { deploymentRoutes } from './routes/deployments';
7
+ import { buildRoutes } from './routes/builds';
7
8
  import { bootstrapRoutes } from './routes/bootstrap';
8
9
  import { signatureAuth } from './middleware/auth';
9
10
 
@@ -26,6 +27,7 @@ export function createApp() {
26
27
  app.use('/api/v1/projects', signatureAuth, projectRoutes);
27
28
  app.use('/api/v1/projects/:projectId/environments', signatureAuth, environmentRoutes);
28
29
  app.use('/api/v1/deployments', signatureAuth, deploymentRoutes);
30
+ app.use('/api/v1/builds', signatureAuth, buildRoutes);
29
31
 
30
32
  return app;
31
33
  }
@@ -0,0 +1,46 @@
1
+ import { Router, raw } from 'express';
2
+ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
3
+ import { applianceBaseConfig, generateId } from '@appliance.sh/sdk';
4
+
5
+ export const buildRoutes = Router();
6
+
7
+ // Accept raw binary body up to 500MB
8
+ buildRoutes.post('/', raw({ type: 'application/octet-stream', limit: '500mb' }), async (req, res) => {
9
+ try {
10
+ const configRaw = process.env.APPLIANCE_BASE_CONFIG;
11
+ if (!configRaw) {
12
+ res.status(500).json({ error: 'Base config not available' });
13
+ return;
14
+ }
15
+ const config = applianceBaseConfig.parse(JSON.parse(configRaw));
16
+
17
+ if (!config.aws.dataBucketName) {
18
+ res.status(500).json({ error: 'Data bucket not configured' });
19
+ return;
20
+ }
21
+
22
+ const body = req.body as Buffer;
23
+ if (!body || body.length === 0) {
24
+ res.status(400).json({ error: 'Empty body' });
25
+ return;
26
+ }
27
+
28
+ const buildId = generateId('build');
29
+ const s3Key = `builds/${buildId}.zip`;
30
+
31
+ const s3 = new S3Client({ region: config.aws.region });
32
+ await s3.send(
33
+ new PutObjectCommand({
34
+ Bucket: config.aws.dataBucketName,
35
+ Key: s3Key,
36
+ Body: body,
37
+ ContentType: 'application/zip',
38
+ })
39
+ );
40
+
41
+ res.status(201).json({ buildId, size: body.length });
42
+ } catch (error) {
43
+ console.error('Build upload error:', error);
44
+ res.status(500).json({ error: 'Failed to upload build' });
45
+ }
46
+ });
@@ -38,9 +38,9 @@ describe('ApiKeyService', () => {
38
38
  service = new ApiKeyService();
39
39
  });
40
40
 
41
- it('should create a key with ak_ prefixed id and sk_ prefixed secret', async () => {
41
+ it('should create a key with apikey_ prefixed id and sk_ prefixed secret', async () => {
42
42
  const result = await service.create('test-key');
43
- expect(result.id).toMatch(/^ak_/);
43
+ expect(result.id).toMatch(/^apikey_/);
44
44
  expect(result.secret).toMatch(/^sk_/);
45
45
  expect(result.name).toBe('test-key');
46
46
  expect(result.createdAt).toBeDefined();
@@ -17,7 +17,7 @@ interface StoredApiKey {
17
17
  export class ApiKeyService {
18
18
  async create(name: string): Promise<ApiKeyCreateResponse> {
19
19
  const storage = getStorageService();
20
- const id = generateId('ak');
20
+ const id = generateId('apikey');
21
21
  const secret = `sk_${randomBytes(32).toString('hex')}`;
22
22
  const now = new Date().toISOString();
23
23
 
@@ -0,0 +1,239 @@
1
+ import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
2
+ import { ECRClient, GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr';
3
+ import { applianceBaseConfig, applianceInput } from '@appliance.sh/sdk';
4
+ import type { ApplianceContainer, ApplianceFrameworkApp } from '@appliance.sh/sdk';
5
+ import { execSync } from 'child_process';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import * as os from 'node:os';
9
+
10
+ export interface ResolvedBuild {
11
+ imageUri?: string;
12
+ codeS3Key?: string;
13
+ runtime?: string;
14
+ handler?: string;
15
+ layers?: string[];
16
+ architectures?: string[];
17
+ environment?: Record<string, string>;
18
+ }
19
+
20
+ function getBaseConfig() {
21
+ const raw = process.env.APPLIANCE_BASE_CONFIG;
22
+ if (!raw) throw new Error('APPLIANCE_BASE_CONFIG not set');
23
+ return applianceBaseConfig.parse(JSON.parse(raw));
24
+ }
25
+
26
+ const LAMBDA_ADAPTER_LAYER: Record<string, string> = {
27
+ 'linux/amd64': 'arn:aws:lambda:${region}:753240598075:layer:LambdaAdapterLayerX86:26',
28
+ 'linux/arm64': 'arn:aws:lambda:${region}:753240598075:layer:LambdaAdapterLayerArm64:26',
29
+ };
30
+
31
+ const FRAMEWORK_RUNTIMES: Record<string, string> = {
32
+ node: 'nodejs22.x',
33
+ python: 'python3.13',
34
+ auto: 'nodejs22.x',
35
+ other: 'nodejs22.x',
36
+ };
37
+
38
+ const FRAMEWORK_ARCHITECTURES: Record<string, string> = {
39
+ 'linux/amd64': 'x86_64',
40
+ 'linux/arm64': 'arm64',
41
+ };
42
+
43
+ export class BuildService {
44
+ async resolve(buildId: string, tag: string): Promise<ResolvedBuild> {
45
+ const config = getBaseConfig();
46
+ if (!config.aws.dataBucketName) throw new Error('Data bucket not configured');
47
+
48
+ const s3Key = `builds/${buildId}.zip`;
49
+ const s3 = new S3Client({ region: config.aws.region });
50
+
51
+ // Download the build zip
52
+ const result = await s3.send(new GetObjectCommand({ Bucket: config.aws.dataBucketName, Key: s3Key }));
53
+ const body = await result.Body?.transformToByteArray();
54
+ if (!body) throw new Error('Empty build');
55
+
56
+ // Extract to temp dir
57
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'appliance-build-'));
58
+ const zipPath = path.join(tmpDir, 'appliance.zip');
59
+ fs.writeFileSync(zipPath, body);
60
+
61
+ try {
62
+ // List zip contents and validate paths before extracting
63
+ const entries = execSync(`zipinfo -1 "${zipPath}"`, { encoding: 'utf-8' }).trim().split('\n');
64
+ for (const entryPath of entries) {
65
+ const resolved = path.resolve(tmpDir, entryPath);
66
+ if (!resolved.startsWith(tmpDir + path.sep) && resolved !== tmpDir) {
67
+ throw new Error(`Zip contains path traversal: ${entryPath}`);
68
+ }
69
+ }
70
+
71
+ execSync(`unzip -o -q "${zipPath}" -d "${tmpDir}"`, { stdio: 'pipe' });
72
+
73
+ // Read the manifest
74
+ const manifestPath = path.join(tmpDir, 'appliance.json');
75
+ if (!fs.existsSync(manifestPath)) throw new Error('Build missing appliance.json');
76
+ const manifest = applianceInput.parse(JSON.parse(fs.readFileSync(manifestPath, 'utf-8')));
77
+
78
+ if (manifest.type === 'container') {
79
+ return this.resolveContainer(tmpDir, manifest, tag, config);
80
+ } else if (manifest.type === 'framework') {
81
+ return this.resolveFramework(tmpDir, manifest, tag, config);
82
+ } else {
83
+ return { codeS3Key: s3Key };
84
+ }
85
+ } finally {
86
+ fs.rmSync(tmpDir, { recursive: true, force: true });
87
+ }
88
+ }
89
+
90
+ private async resolveFramework(
91
+ tmpDir: string,
92
+ manifest: ApplianceFrameworkApp,
93
+ tag: string,
94
+ config: ReturnType<typeof getBaseConfig>
95
+ ): Promise<ResolvedBuild> {
96
+ if (!config.aws.dataBucketName) throw new Error('Data bucket not configured');
97
+
98
+ const port = manifest.port ?? 8080;
99
+ const framework = manifest.framework ?? 'auto';
100
+ const detectedFramework = framework === 'auto' ? this.detectFramework(tmpDir) : framework;
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 });
107
+
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
+ const platform = manifest.platform;
126
+ const layerArn = LAMBDA_ADAPTER_LAYER[platform]?.replace('${region}', config.aws.region);
127
+ if (!layerArn) throw new Error(`No Lambda Web Adapter layer for platform: ${platform}`);
128
+ const architecture = FRAMEWORK_ARCHITECTURES[platform];
129
+ if (!architecture) throw new Error(`No Lambda architecture for platform: ${platform}`);
130
+
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
+ return {
136
+ codeS3Key: repackagedKey,
137
+ runtime,
138
+ handler: 'run.sh',
139
+ layers: [layerArn],
140
+ architectures: [architecture],
141
+ environment: {
142
+ AWS_LAMBDA_EXEC_WRAPPER: '/opt/bootstrap',
143
+ AWS_LWA_PORT: String(port),
144
+ },
145
+ };
146
+ }
147
+
148
+ private detectFramework(tmpDir: string): string {
149
+ if (fs.existsSync(path.join(tmpDir, 'package.json'))) return 'node';
150
+ if (fs.existsSync(path.join(tmpDir, 'requirements.txt'))) return 'python';
151
+ if (fs.existsSync(path.join(tmpDir, 'Pipfile'))) return 'python';
152
+ if (fs.existsSync(path.join(tmpDir, 'pyproject.toml'))) return 'python';
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
+
168
+ private async resolveContainer(
169
+ tmpDir: string,
170
+ manifest: ApplianceContainer,
171
+ tag: string,
172
+ config: ReturnType<typeof getBaseConfig>
173
+ ): Promise<ResolvedBuild> {
174
+ const ecrRepositoryUrl = config.aws.ecrRepositoryUrl;
175
+ if (!ecrRepositoryUrl) throw new Error('ECR repository not configured');
176
+
177
+ const imageTarPath = path.join(tmpDir, 'image.tar');
178
+ if (!fs.existsSync(imageTarPath)) throw new Error('Build missing image.tar');
179
+
180
+ // Load the user's original image into Docker and capture the loaded image reference
181
+ const loadOutput = execSync(`docker load -i "${imageTarPath}"`, { encoding: 'utf-8' });
182
+ // Output is like "Loaded image: name:tag" or "Loaded image ID: sha256:abc..."
183
+ const loadedMatch = loadOutput.match(/Loaded image(?: ID)?:\s*(.+)/);
184
+ if (!loadedMatch) throw new Error(`Failed to parse docker load output: ${loadOutput}`);
185
+ const loadedImage = loadedMatch[1].trim();
186
+
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
+ // Auth with ECR
206
+ const ecr = new ECRClient({ region: config.aws.region });
207
+ const authResult = await ecr.send(new GetAuthorizationTokenCommand({}));
208
+ const authData = authResult.authorizationData?.[0];
209
+ if (!authData?.authorizationToken || !authData?.proxyEndpoint) {
210
+ throw new Error('Failed to get ECR auth');
211
+ }
212
+
213
+ const decoded = Buffer.from(authData.authorizationToken, 'base64').toString();
214
+ const [username, password] = decoded.split(':');
215
+ execSync(`docker login --username ${username} --password-stdin ${authData.proxyEndpoint}`, {
216
+ input: password,
217
+ stdio: ['pipe', 'pipe', 'pipe'],
218
+ });
219
+
220
+ // Tag and push the Lambda-wrapped image
221
+ const remoteTag = `${ecrRepositoryUrl}:${tag}`;
222
+ execSync(`docker tag ${lambdaImageName} ${remoteTag}`, { stdio: 'pipe' });
223
+ execSync(`docker push ${remoteTag}`, { stdio: 'pipe' });
224
+
225
+ // Get digest
226
+ let imageUri: string;
227
+ try {
228
+ imageUri = execSync(`docker inspect --format='{{index .RepoDigests 0}}' ${remoteTag}`, {
229
+ encoding: 'utf-8',
230
+ }).trim();
231
+ } catch {
232
+ imageUri = remoteTag;
233
+ }
234
+
235
+ return { imageUri };
236
+ }
237
+ }
238
+
239
+ export const buildService = new BuildService();
@@ -10,6 +10,7 @@ import { createApplianceDeploymentService } from '@appliance.sh/infra';
10
10
  import { getStorageService } from './storage.service';
11
11
  import { environmentService } from './environment.service';
12
12
  import { projectService } from './project.service';
13
+ import { buildService } from './build.service';
13
14
 
14
15
  const COLLECTION = 'deployments';
15
16
 
@@ -25,7 +26,7 @@ export class DeploymentService {
25
26
  const now = new Date().toISOString();
26
27
  const deployment: Deployment = {
27
28
  ...input,
28
- id: generateId('dep'),
29
+ id: generateId('deployment'),
29
30
  projectId: environment.projectId,
30
31
  status: DeploymentStatus.Pending,
31
32
  startedAt: now,
@@ -59,13 +60,18 @@ export class DeploymentService {
59
60
 
60
61
  // Execute the deployment
61
62
  try {
62
- const deploymentService = createApplianceDeploymentService();
63
+ const infraService = createApplianceDeploymentService();
63
64
 
64
65
  let result;
65
66
  if (input.action === DeploymentAction.Deploy) {
66
- result = await deploymentService.deploy(environment.stackName, metadata);
67
+ // Resolve the build into cloud-specific params if present
68
+ const build = input.buildId
69
+ ? await buildService.resolve(input.buildId, `${environment.stackName}-${deployment.id}`)
70
+ : undefined;
71
+
72
+ result = await infraService.deploy(environment.stackName, metadata, build);
67
73
  } else {
68
- result = await deploymentService.destroy(environment.stackName);
74
+ result = await infraService.destroy(environment.stackName);
69
75
  }
70
76
 
71
77
  deployment.status = DeploymentStatus.Succeeded;
@@ -7,7 +7,7 @@ export class EnvironmentService {
7
7
  async create(input: EnvironmentInput, projectName: string): Promise<Environment> {
8
8
  const storage = getStorageService();
9
9
  const now = new Date().toISOString();
10
- const id = generateId('env');
10
+ const id = generateId('environment');
11
11
  const environment: Environment = {
12
12
  ...input,
13
13
  id,
@@ -9,7 +9,7 @@ export class ProjectService {
9
9
  const now = new Date().toISOString();
10
10
  const project: Project = {
11
11
  ...input,
12
- id: generateId('proj'),
12
+ id: generateId('project'),
13
13
  status: ProjectStatus.Active,
14
14
  createdAt: now,
15
15
  updatedAt: now,