@appliance.sh/api-server 1.6.0 → 1.8.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.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "",
5
5
  "author": "Eliot Lim",
6
6
  "repository": "https://github.com/appliance-sh/appliance.sh",
@@ -23,9 +23,8 @@
23
23
  "@nestjs/common": "^11.0.1",
24
24
  "@nestjs/core": "^11.0.1",
25
25
  "@nestjs/platform-express": "^11.0.1",
26
- "@aws-sdk/client-cloudformation": "^3.679.0",
27
- "aws-cdk-lib": "^2.230.0",
28
- "constructs": "^10.4.3",
26
+ "@pulumi/aws": "^7.15.0",
27
+ "@pulumi/pulumi": "^3.213.0",
29
28
  "reflect-metadata": "^0.2.2",
30
29
  "rxjs": "^7.8.1"
31
30
  },
package/src/app.module.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { Module } from '@nestjs/common';
2
2
  import { AppController } from './app.controller';
3
3
  import { AppService } from './app.service';
4
- import { ApplianceStackModule } from './cdk/appliance-stack.module';
4
+ import { PulumiModule } from './pulumi/pulumi.module';
5
5
 
6
6
  @Module({
7
- imports: [ApplianceStackModule],
7
+ imports: [PulumiModule],
8
8
  controllers: [AppController],
9
9
  providers: [AppService],
10
10
  })
@@ -0,0 +1,86 @@
1
+ import * as pulumi from '@pulumi/pulumi';
2
+ import * as aws from '@pulumi/aws';
3
+
4
+ export interface ApplianceStackArgs {
5
+ tags?: Record<string, string>;
6
+ }
7
+
8
+ export interface ApplianceStackOpts extends pulumi.ComponentResourceOptions {
9
+ globalProvider: aws.Provider;
10
+ provider: aws.Provider;
11
+ }
12
+
13
+ export class ApplianceStack extends pulumi.ComponentResource {
14
+ lambdaRole: aws.iam.Role;
15
+ lambdaRolePolicy: aws.iam.Policy;
16
+ lambda: aws.lambda.Function;
17
+ lambdaUrl: aws.lambda.FunctionUrl;
18
+
19
+ constructor(name: string, args: ApplianceStackArgs, opts: ApplianceStackOpts) {
20
+ super('appliance:aws:ApplianceStack', name, args, opts);
21
+
22
+ const defaultOpts = { parent: this, provider: opts.provider };
23
+ const defaultTags = { stack: name, managed: 'appliance', ...args.tags };
24
+
25
+ this.lambdaRole = new aws.iam.Role(`${name}-role`, {
26
+ path: `/appliance/${name}/`,
27
+ assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ Service: 'lambda.amazonaws.com' }),
28
+ tags: defaultTags,
29
+ });
30
+
31
+ this.lambdaRolePolicy = new aws.iam.Policy(`${name}-policy`, {
32
+ path: `/appliance/${name}/`,
33
+ policy: {
34
+ Version: '2012-10-17',
35
+ Statement: [{ Effect: 'Allow', Action: 'logs:CreateLogGroup', Resource: '*' }],
36
+ },
37
+ });
38
+
39
+ new aws.iam.RolePolicyAttachment(`${name}-role-policy-attachment`, {
40
+ role: this.lambdaRole.name,
41
+ policyArn: this.lambdaRolePolicy.arn,
42
+ });
43
+
44
+ this.lambda = new aws.lambda.CallbackFunction(
45
+ `${name}-handler`,
46
+ {
47
+ runtime: 'nodejs22.x',
48
+ callback: async () => {
49
+ return { statusCode: 200, body: JSON.stringify({ message: 'Hello world!' }) };
50
+ },
51
+ tags: defaultTags,
52
+ },
53
+ defaultOpts
54
+ );
55
+
56
+ // lambda url
57
+ this.lambdaUrl = new aws.lambda.FunctionUrl(
58
+ `${name}-url`,
59
+ {
60
+ functionName: this.lambda.name,
61
+ authorizationType: 'NONE',
62
+ },
63
+ defaultOpts
64
+ );
65
+
66
+ new aws.lambda.Permission(`${name}-url-invoke-url-permission`, {
67
+ function: this.lambda.name,
68
+ action: 'lambda:InvokeFunctionUrl',
69
+ principal: '*',
70
+ functionUrlAuthType: 'NONE',
71
+ statementId: 'FunctionURLAllowPublicAccess',
72
+ });
73
+
74
+ new aws.lambda.Permission(`${name}-url-invoke-lambda-permission`, {
75
+ function: this.lambda.name,
76
+ action: 'lambda:InvokeFunction',
77
+ principal: '*',
78
+ statementId: 'FunctionURLAllowInvokeAction',
79
+ });
80
+
81
+ this.registerOutputs({
82
+ lambda: this.lambda,
83
+ lambdaUrl: this.lambdaUrl,
84
+ });
85
+ }
86
+ }
@@ -0,0 +1,19 @@
1
+ import { Controller, HttpCode, Post } from '@nestjs/common';
2
+ import { PulumiService } from './pulumi.service';
3
+
4
+ @Controller('infra')
5
+ export class PulumiController {
6
+ constructor(private readonly pulumi: PulumiService) {}
7
+
8
+ @Post('deploy')
9
+ @HttpCode(200)
10
+ async deploy() {
11
+ return await this.pulumi.deploy();
12
+ }
13
+
14
+ @Post('destroy')
15
+ @HttpCode(200)
16
+ async destroy() {
17
+ return await this.pulumi.destroy();
18
+ }
19
+ }
@@ -0,0 +1,9 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { PulumiService } from './pulumi.service';
3
+ import { PulumiController } from './pulumi.controller';
4
+
5
+ @Module({
6
+ providers: [PulumiService],
7
+ controllers: [PulumiController],
8
+ })
9
+ export class PulumiModule {}
@@ -0,0 +1,127 @@
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import * as path from 'node:path';
3
+ import * as fs from 'node:fs';
4
+ import * as auto from '@pulumi/pulumi/automation';
5
+ import * as aws from '@pulumi/aws';
6
+ import { ApplianceStack } from './ApplianceStack';
7
+
8
+ export type PulumiAction = 'deploy' | 'destroy';
9
+
10
+ export interface PulumiResult {
11
+ action: PulumiAction;
12
+ ok: boolean;
13
+ idempotentNoop: boolean;
14
+ message: string;
15
+ stackName: string;
16
+ }
17
+
18
+ @Injectable()
19
+ export class PulumiService {
20
+ private readonly logger = new Logger(PulumiService.name);
21
+ private readonly region = process.env.AWS_REGION || 'us-east-1';
22
+ private readonly backendDir = this.ensureDir(path.resolve(__dirname, '../../.pulumi-state'));
23
+ private readonly projectName = 'appliance-api-managed-proj';
24
+
25
+ private ensureDir(dir: string): string {
26
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
27
+ return dir;
28
+ }
29
+
30
+ private inlineProgram() {
31
+ return async () => {
32
+ const name = 'appliance';
33
+ const regionalProvider = new aws.Provider(`${name}-regional`, {
34
+ region: 'ap-southeast-1',
35
+ });
36
+ const globalProvider = new aws.Provider(`${name}-global`, {
37
+ region: 'us-east-1',
38
+ });
39
+
40
+ const applianceStack = new ApplianceStack(
41
+ `${name}-stack`,
42
+ {
43
+ tags: { project: name },
44
+ },
45
+ {
46
+ globalProvider,
47
+ provider: regionalProvider,
48
+ }
49
+ );
50
+
51
+ const bucket = new aws.s3.Bucket('dummy-bucket', {
52
+ forceDestroy: true,
53
+ versioning: { enabled: true },
54
+ tags: { app: 'appliance-api' },
55
+ });
56
+ return {
57
+ applianceStack,
58
+ bucketName: bucket.bucket,
59
+ };
60
+ };
61
+ }
62
+
63
+ private async getOrCreateStack(stackName: string): Promise<auto.Stack> {
64
+ const program = this.inlineProgram();
65
+ const envVars: Record<string, string> = {
66
+ PULUMI_BACKEND_URL: 'file://' + this.backendDir,
67
+ AWS_REGION: this.region,
68
+ };
69
+ const stack = await auto.LocalWorkspace.createOrSelectStack(
70
+ { projectName: this.projectName, stackName, program },
71
+ { envVars }
72
+ );
73
+ await stack.setConfig('aws:region', { value: this.region });
74
+ return stack;
75
+ }
76
+
77
+ private async selectExistingStack(stackName: string): Promise<auto.Stack> {
78
+ const envVars: Record<string, string> = {
79
+ PULUMI_BACKEND_URL: 'file://' + this.backendDir,
80
+ AWS_REGION: this.region,
81
+ };
82
+ const ws = await auto.LocalWorkspace.create({
83
+ projectSettings: { name: this.projectName, runtime: 'nodejs' },
84
+ envVars,
85
+ });
86
+
87
+ return auto.Stack.createOrSelect(stackName, ws);
88
+ }
89
+
90
+ async deploy(stackName = 'appliance-api-managed'): Promise<PulumiResult> {
91
+ const stack = await this.getOrCreateStack(stackName);
92
+ const result = await stack.up({ onOutput: (m) => this.logger.log(m) });
93
+ const changes = result.summary.resourceChanges || {};
94
+ const totalChanges = Object.entries(changes)
95
+ .filter(([k]) => k !== 'same')
96
+ .reduce((acc, [, v]) => acc + (v || 0), 0);
97
+ const idempotentNoop = totalChanges === 0;
98
+ return {
99
+ action: 'deploy',
100
+ ok: true,
101
+ idempotentNoop,
102
+ message: idempotentNoop ? 'No changes (idempotent)' : 'Stack updated',
103
+ stackName,
104
+ };
105
+ }
106
+
107
+ async destroy(stackName = 'appliance-api-managed'): Promise<PulumiResult> {
108
+ try {
109
+ const stack = await this.selectExistingStack(stackName);
110
+ await stack.destroy({ onOutput: (m) => this.logger.log(m) });
111
+ return { action: 'destroy', ok: true, idempotentNoop: false, message: 'Stack resources deleted', stackName };
112
+ } catch (e) {
113
+ if (!(e instanceof Error)) throw e;
114
+ const msg = String(e?.message || e);
115
+ if (msg.includes('no stack named') || msg.includes('not found')) {
116
+ return {
117
+ action: 'destroy',
118
+ ok: true,
119
+ idempotentNoop: true,
120
+ message: 'Stack not found (idempotent)',
121
+ stackName,
122
+ };
123
+ }
124
+ throw e;
125
+ }
126
+ }
127
+ }
@@ -1,131 +0,0 @@
1
- import { Injectable, Logger } from '@nestjs/common';
2
- import * as cdk from 'aws-cdk-lib';
3
- import { ApplianceStack } from './appliance-stack';
4
- import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
5
- import { tmpdir } from 'node:os';
6
- import * as path from 'node:path';
7
- import {
8
- CloudFormationClient,
9
- CreateStackCommand,
10
- DeleteStackCommand,
11
- DescribeStacksCommand,
12
- UpdateStackCommand,
13
- } from '@aws-sdk/client-cloudformation';
14
- import {
15
- waitUntilStackCreateComplete,
16
- waitUntilStackUpdateComplete,
17
- waitUntilStackDeleteComplete,
18
- } from '@aws-sdk/client-cloudformation';
19
-
20
- export type CdkAction = 'deploy' | 'destroy';
21
-
22
- export interface CdkResult {
23
- action: CdkAction;
24
- ok: boolean;
25
- idempotentNoop: boolean;
26
- message: string;
27
- stackName: string;
28
- }
29
-
30
- @Injectable()
31
- export class ApplianceStackAwsCdkService {
32
- private readonly logger = new Logger(ApplianceStackAwsCdkService.name);
33
- private readonly region = process.env.AWS_REGION || 'us-east-1';
34
-
35
- private client(): CloudFormationClient {
36
- return new CloudFormationClient({ region: this.region });
37
- }
38
-
39
- private synthTemplate(stackName: string): { templateBody: string; outdir: string } {
40
- const outdir = mkdtempSync(path.join(tmpdir(), 'appliance-cdk-'));
41
- const app = new cdk.App({ outdir });
42
- new ApplianceStack(app, stackName, {});
43
- const assembly = app.synth();
44
- const artifact = assembly.getStackByName(stackName);
45
- const templatePath = path.join(assembly.directory, artifact.templateFile);
46
- const templateBody = readFileSync(templatePath, 'utf-8');
47
- return { templateBody, outdir: assembly.directory };
48
- }
49
-
50
- async deploy(stackName = 'appliance-api-managed'): Promise<CdkResult> {
51
- const { templateBody, outdir } = this.synthTemplate(stackName);
52
- try {
53
- // Does stack exist?
54
- const cf = this.client();
55
- const exists = await this.stackExists(cf, stackName);
56
- if (!exists) {
57
- await cf.send(
58
- new CreateStackCommand({
59
- StackName: stackName,
60
- TemplateBody: templateBody,
61
- Capabilities: ['CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'],
62
- })
63
- );
64
- await waitUntilStackCreateComplete(
65
- { client: cf, maxWaitTime: 30 * 60, minDelay: 5, maxDelay: 20 },
66
- { StackName: stackName }
67
- );
68
- return { action: 'deploy', ok: true, idempotentNoop: false, message: 'Stack created', stackName };
69
- }
70
-
71
- try {
72
- await cf.send(
73
- new UpdateStackCommand({
74
- StackName: stackName,
75
- TemplateBody: templateBody,
76
- Capabilities: ['CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'],
77
- })
78
- );
79
- await waitUntilStackUpdateComplete(
80
- { client: cf, maxWaitTime: 30 * 60, minDelay: 5, maxDelay: 20 },
81
- { StackName: stackName }
82
- );
83
- return { action: 'deploy', ok: true, idempotentNoop: false, message: 'Stack updated', stackName };
84
- } catch (e) {
85
- if (
86
- e instanceof Error &&
87
- typeof e?.message === 'string' &&
88
- e.message.includes('No updates are to be performed')
89
- ) {
90
- return { action: 'deploy', ok: true, idempotentNoop: true, message: 'No changes (idempotent)', stackName };
91
- }
92
- throw e;
93
- }
94
- } finally {
95
- // cleanup synth output
96
- try {
97
- rmSync(outdir, { recursive: true, force: true });
98
- } catch {
99
- // ignore
100
- }
101
- }
102
- }
103
-
104
- async destroy(stackName = 'appliance-api-managed'): Promise<CdkResult> {
105
- const cf = this.client();
106
- const exists = await this.stackExists(cf, stackName);
107
- if (!exists) {
108
- return { action: 'destroy', ok: true, idempotentNoop: true, message: 'Stack not found (idempotent)', stackName };
109
- }
110
- await cf.send(new DeleteStackCommand({ StackName: stackName }));
111
- await waitUntilStackDeleteComplete(
112
- { client: cf, maxWaitTime: 30 * 60, minDelay: 5, maxDelay: 20 },
113
- { StackName: stackName }
114
- );
115
- return { action: 'destroy', ok: true, idempotentNoop: false, message: 'Stack deleted', stackName };
116
- }
117
-
118
- private async stackExists(cf: CloudFormationClient, stackName: string): Promise<boolean> {
119
- try {
120
- const res = await cf.send(new DescribeStacksCommand({ StackName: stackName }));
121
- return !!res.Stacks && res.Stacks.length > 0;
122
- } catch (e) {
123
- if (!(e instanceof Error)) throw e;
124
- const code = e?.name;
125
- const message = e?.message || '';
126
- if (code === 'ValidationError' && message.includes('does not exist')) return false;
127
- if (message.includes('does not exist')) return false;
128
- throw e;
129
- }
130
- }
131
- }
@@ -1,19 +0,0 @@
1
- import { Controller, HttpCode, Post } from '@nestjs/common';
2
- import { ApplianceStackAwsCdkService } from './appliance-stack-aws-cdk.service';
3
-
4
- @Controller('infra')
5
- export class ApplianceStackController {
6
- constructor(private readonly cdk: ApplianceStackAwsCdkService) {}
7
-
8
- @Post('deploy')
9
- @HttpCode(200)
10
- async deploy() {
11
- return await this.cdk.deploy();
12
- }
13
-
14
- @Post('destroy')
15
- @HttpCode(200)
16
- async destroy() {
17
- return await this.cdk.destroy();
18
- }
19
- }
@@ -1,9 +0,0 @@
1
- import { Module } from '@nestjs/common';
2
- import { ApplianceStackAwsCdkService } from './appliance-stack-aws-cdk.service';
3
- import { ApplianceStackController } from './appliance-stack.controller';
4
-
5
- @Module({
6
- providers: [ApplianceStackAwsCdkService],
7
- controllers: [ApplianceStackController],
8
- })
9
- export class ApplianceStackModule {}
@@ -1,35 +0,0 @@
1
- import * as cdk from 'aws-cdk-lib';
2
- import { Construct } from 'constructs';
3
-
4
- export class ApplianceStack extends cdk.Stack {
5
- constructor(scope: Construct, id: string, props?: cdk.StackProps) {
6
- super(scope, id, props);
7
-
8
- // Simple dummy resource: S3 bucket with a stable logical ID
9
- const bucket = new cdk.aws_s3.Bucket(this, 'DummyBucket', {
10
- versioned: true,
11
- removalPolicy: cdk.RemovalPolicy.DESTROY,
12
- // autoDeleteObjects: true,
13
- });
14
-
15
- const lambdaUrl = new cdk.aws_lambda.Function(this, 'DummyLambda', {
16
- runtime: cdk.aws_lambda.Runtime.NODEJS_24_X,
17
- handler: 'index.handler',
18
- code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => "Hello World!";'),
19
- });
20
-
21
- const fnUrl = lambdaUrl.addFunctionUrl({
22
- authType: cdk.aws_lambda.FunctionUrlAuthType.AWS_IAM,
23
- });
24
-
25
- const fnDistribution = new cdk.aws_cloudfront.Distribution(this, 'DummyDistribution', {
26
- defaultBehavior: {
27
- origin: cdk.aws_cloudfront_origins.FunctionUrlOrigin.withOriginAccessControl(fnUrl),
28
- },
29
- });
30
-
31
- new cdk.CfnOutput(this, 'BucketName', { value: bucket.bucketName });
32
- new cdk.CfnOutput(this, 'FunctionUrl', { value: fnUrl.url });
33
- new cdk.CfnOutput(this, 'Distribution', { value: fnDistribution.distributionDomainName });
34
- }
35
- }