@appliance.sh/api-server 1.19.0 → 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 +4 -3
- package/src/main.ts +2 -0
- package/src/routes/builds/index.ts +46 -0
- package/src/routes/environments/index.ts +2 -9
- package/src/services/api-key.service.spec.ts +2 -2
- package/src/services/api-key.service.ts +1 -1
- package/src/services/build.service.ts +138 -0
- package/src/services/deployment.service.ts +10 -6
- package/src/services/environment.service.ts +2 -4
- package/src/services/project.service.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appliance.sh/api-server",
|
|
3
|
-
"version": "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.
|
|
23
|
-
"@appliance.sh/sdk": "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
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
|
-
import { environmentInput
|
|
2
|
+
import { environmentInput } from '@appliance.sh/sdk';
|
|
3
3
|
import { environmentService } from '../../services/environment.service';
|
|
4
4
|
import { projectService } from '../../services/project.service';
|
|
5
5
|
|
|
@@ -8,12 +8,6 @@ interface EnvironmentParams {
|
|
|
8
8
|
id?: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
function getServerBaseConfig() {
|
|
12
|
-
const raw = process.env.APPLIANCE_BASE_CONFIG;
|
|
13
|
-
if (!raw) throw new Error('APPLIANCE_BASE_CONFIG is not set on the server');
|
|
14
|
-
return applianceBaseConfig.parse(JSON.parse(raw));
|
|
15
|
-
}
|
|
16
|
-
|
|
17
11
|
export const environmentRoutes = Router({ mergeParams: true });
|
|
18
12
|
|
|
19
13
|
environmentRoutes.post('/', async (req, res) => {
|
|
@@ -24,12 +18,11 @@ environmentRoutes.post('/', async (req, res) => {
|
|
|
24
18
|
res.status(404).json({ error: 'Project not found' });
|
|
25
19
|
return;
|
|
26
20
|
}
|
|
27
|
-
const baseConfig = getServerBaseConfig();
|
|
28
21
|
const input = environmentInput.parse({
|
|
29
22
|
...req.body,
|
|
30
23
|
projectId: params.projectId,
|
|
31
24
|
});
|
|
32
|
-
const environment = await environmentService.create(input, project.name
|
|
25
|
+
const environment = await environmentService.create(input, project.name);
|
|
33
26
|
res.status(201).json(environment);
|
|
34
27
|
} catch (error) {
|
|
35
28
|
console.error('Create environment error:', error);
|
|
@@ -38,9 +38,9 @@ describe('ApiKeyService', () => {
|
|
|
38
38
|
service = new ApiKeyService();
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
it('should create a key with
|
|
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(/^
|
|
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('
|
|
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('
|
|
29
|
+
id: generateId('deployment'),
|
|
29
30
|
projectId: environment.projectId,
|
|
30
31
|
status: DeploymentStatus.Pending,
|
|
31
32
|
startedAt: now,
|
|
@@ -59,15 +60,18 @@ export class DeploymentService {
|
|
|
59
60
|
|
|
60
61
|
// Execute the deployment
|
|
61
62
|
try {
|
|
62
|
-
const
|
|
63
|
-
baseConfig: environment.baseConfig,
|
|
64
|
-
});
|
|
63
|
+
const infraService = createApplianceDeploymentService();
|
|
65
64
|
|
|
66
65
|
let result;
|
|
67
66
|
if (input.action === DeploymentAction.Deploy) {
|
|
68
|
-
|
|
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);
|
|
69
73
|
} else {
|
|
70
|
-
result = await
|
|
74
|
+
result = await infraService.destroy(environment.stackName);
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
deployment.status = DeploymentStatus.Succeeded;
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
import { Environment, EnvironmentInput, EnvironmentStatus, generateId } from '@appliance.sh/sdk';
|
|
2
|
-
import type { ApplianceBaseConfig } from '@appliance.sh/sdk';
|
|
3
2
|
import { getStorageService } from './storage.service';
|
|
4
3
|
|
|
5
4
|
const COLLECTION = 'environments';
|
|
6
5
|
|
|
7
6
|
export class EnvironmentService {
|
|
8
|
-
async create(input: EnvironmentInput, projectName: string
|
|
7
|
+
async create(input: EnvironmentInput, projectName: string): Promise<Environment> {
|
|
9
8
|
const storage = getStorageService();
|
|
10
9
|
const now = new Date().toISOString();
|
|
11
|
-
const id = generateId('
|
|
10
|
+
const id = generateId('environment');
|
|
12
11
|
const environment: Environment = {
|
|
13
12
|
...input,
|
|
14
13
|
id,
|
|
15
|
-
baseConfig,
|
|
16
14
|
status: EnvironmentStatus.Pending,
|
|
17
15
|
stackName: `${projectName}-${input.name}`,
|
|
18
16
|
createdAt: now,
|