@appliance.sh/api-server 1.19.1 → 1.20.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.20.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.20.0",
23
+ "@appliance.sh/sdk": "1.20.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,138 @@
1
+ import { S3Client, GetObjectCommand } 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 } 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
+ }
14
+
15
+ function getBaseConfig() {
16
+ const raw = process.env.APPLIANCE_BASE_CONFIG;
17
+ if (!raw) throw new Error('APPLIANCE_BASE_CONFIG not set');
18
+ return applianceBaseConfig.parse(JSON.parse(raw));
19
+ }
20
+
21
+ export class BuildService {
22
+ async resolve(buildId: string, tag: string): Promise<ResolvedBuild> {
23
+ const config = getBaseConfig();
24
+ if (!config.aws.dataBucketName) throw new Error('Data bucket not configured');
25
+
26
+ const s3Key = `builds/${buildId}.zip`;
27
+ const s3 = new S3Client({ region: config.aws.region });
28
+
29
+ // Download the build zip
30
+ const result = await s3.send(new GetObjectCommand({ Bucket: config.aws.dataBucketName, Key: s3Key }));
31
+ const body = await result.Body?.transformToByteArray();
32
+ if (!body) throw new Error('Empty build');
33
+
34
+ // Extract to temp dir
35
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'appliance-build-'));
36
+ const zipPath = path.join(tmpDir, 'appliance.zip');
37
+ fs.writeFileSync(zipPath, body);
38
+
39
+ try {
40
+ // List zip contents and validate paths before extracting
41
+ const entries = execSync(`zipinfo -1 "${zipPath}"`, { encoding: 'utf-8' }).trim().split('\n');
42
+ for (const entryPath of entries) {
43
+ const resolved = path.resolve(tmpDir, entryPath);
44
+ if (!resolved.startsWith(tmpDir + path.sep) && resolved !== tmpDir) {
45
+ throw new Error(`Zip contains path traversal: ${entryPath}`);
46
+ }
47
+ }
48
+
49
+ execSync(`unzip -o -q "${zipPath}" -d "${tmpDir}"`, { stdio: 'pipe' });
50
+
51
+ // Read the manifest
52
+ const manifestPath = path.join(tmpDir, 'appliance.json');
53
+ if (!fs.existsSync(manifestPath)) throw new Error('Build missing appliance.json');
54
+ const manifest = applianceInput.parse(JSON.parse(fs.readFileSync(manifestPath, 'utf-8')));
55
+
56
+ if (manifest.type === 'container') {
57
+ return this.resolveContainer(tmpDir, manifest, tag, config);
58
+ } else {
59
+ // For framework/other, the zip is already in S3 — just reference it
60
+ return { codeS3Key: s3Key };
61
+ }
62
+ } finally {
63
+ fs.rmSync(tmpDir, { recursive: true, force: true });
64
+ }
65
+ }
66
+
67
+ private async resolveContainer(
68
+ tmpDir: string,
69
+ manifest: ApplianceContainer,
70
+ tag: string,
71
+ config: ReturnType<typeof getBaseConfig>
72
+ ): Promise<ResolvedBuild> {
73
+ const ecrRepositoryUrl = config.aws.ecrRepositoryUrl;
74
+ if (!ecrRepositoryUrl) throw new Error('ECR repository not configured');
75
+
76
+ const imageTarPath = path.join(tmpDir, 'image.tar');
77
+ if (!fs.existsSync(imageTarPath)) throw new Error('Build missing image.tar');
78
+
79
+ // Load the user's original image into Docker and capture the loaded image reference
80
+ const loadOutput = execSync(`docker load -i "${imageTarPath}"`, { encoding: 'utf-8' });
81
+ // Output is like "Loaded image: name:tag" or "Loaded image ID: sha256:abc..."
82
+ const loadedMatch = loadOutput.match(/Loaded image(?: ID)?:\s*(.+)/);
83
+ if (!loadedMatch) throw new Error(`Failed to parse docker load output: ${loadOutput}`);
84
+ const loadedImage = loadedMatch[1].trim();
85
+
86
+ // Wrap the image with the Lambda Web Adapter so the same plain HTTP
87
+ // container works on both Lambda and ECS/Fargate without any changes.
88
+ const lambdaImageName = `${manifest.name}-lambda`;
89
+ const wrapperDockerfile = path.join(tmpDir, 'Dockerfile.lambda');
90
+ fs.writeFileSync(
91
+ wrapperDockerfile,
92
+ [
93
+ `FROM --platform=${manifest.platform} public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 AS adapter`,
94
+ `FROM ${loadedImage}`,
95
+ `COPY --from=adapter /lambda-adapter /opt/extensions/lambda-adapter`,
96
+ `ENV AWS_LWA_PORT=${manifest.port}`,
97
+ ].join('\n')
98
+ );
99
+ execSync(
100
+ `docker build --platform ${manifest.platform} --provenance=false -f "${wrapperDockerfile}" -t "${lambdaImageName}" "${tmpDir}"`,
101
+ { stdio: 'pipe' }
102
+ );
103
+
104
+ // Auth with ECR
105
+ const ecr = new ECRClient({ region: config.aws.region });
106
+ const authResult = await ecr.send(new GetAuthorizationTokenCommand({}));
107
+ const authData = authResult.authorizationData?.[0];
108
+ if (!authData?.authorizationToken || !authData?.proxyEndpoint) {
109
+ throw new Error('Failed to get ECR auth');
110
+ }
111
+
112
+ const decoded = Buffer.from(authData.authorizationToken, 'base64').toString();
113
+ const [username, password] = decoded.split(':');
114
+ execSync(`docker login --username ${username} --password-stdin ${authData.proxyEndpoint}`, {
115
+ input: password,
116
+ stdio: ['pipe', 'pipe', 'pipe'],
117
+ });
118
+
119
+ // Tag and push the Lambda-wrapped image
120
+ const remoteTag = `${ecrRepositoryUrl}:${tag}`;
121
+ execSync(`docker tag ${lambdaImageName} ${remoteTag}`, { stdio: 'pipe' });
122
+ execSync(`docker push ${remoteTag}`, { stdio: 'pipe' });
123
+
124
+ // Get digest
125
+ let imageUri: string;
126
+ try {
127
+ imageUri = execSync(`docker inspect --format='{{index .RepoDigests 0}}' ${remoteTag}`, {
128
+ encoding: 'utf-8',
129
+ }).trim();
130
+ } catch {
131
+ imageUri = remoteTag;
132
+ }
133
+
134
+ return { imageUri };
135
+ }
136
+ }
137
+
138
+ 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,