puls-dev 0.2.0 → 0.2.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.
@@ -7,6 +7,9 @@ import { FargateBuilder } from "./fargate.js";
7
7
  import { RDSBuilder } from "./rds.js";
8
8
  import { SQSBuilder } from "./sqs.js";
9
9
  import { SecretsBuilder } from "./secrets.js";
10
+ import { IAMRoleBuilder, IAMPolicyBuilder } from "./iam.js";
11
+ import { SNSTopicBuilder } from "./sns.js";
12
+ import { CloudWatchAlarmBuilder } from "./cloudwatch.js";
10
13
  export declare const AWS: {
11
14
  init: (opts: {
12
15
  region: string;
@@ -20,5 +23,9 @@ export declare const AWS: {
20
23
  RDS: (name: string) => RDSBuilder;
21
24
  SQS: (name: string) => SQSBuilder;
22
25
  Secret: (secretId: string) => SecretsBuilder;
26
+ IAMRole: (name: string) => IAMRoleBuilder;
27
+ IAMPolicy: (name: string) => IAMPolicyBuilder;
28
+ SNS: (name: string) => SNSTopicBuilder;
29
+ Alarm: (name: string) => CloudWatchAlarmBuilder;
23
30
  };
24
31
  export * from "../../types/aws.js";
@@ -8,6 +8,9 @@ import { FargateBuilder } from "./fargate.js";
8
8
  import { RDSBuilder } from "./rds.js";
9
9
  import { SQSBuilder } from "./sqs.js";
10
10
  import { SecretsBuilder } from "./secrets.js";
11
+ import { IAMRoleBuilder, IAMPolicyBuilder } from "./iam.js";
12
+ import { SNSTopicBuilder } from "./sns.js";
13
+ import { CloudWatchAlarmBuilder } from "./cloudwatch.js";
11
14
  export const AWS = {
12
15
  init: (opts) => {
13
16
  Config.set({
@@ -26,5 +29,9 @@ export const AWS = {
26
29
  RDS: (name) => new RDSBuilder(name),
27
30
  SQS: (name) => new SQSBuilder(name),
28
31
  Secret: (secretId) => new SecretsBuilder(secretId),
32
+ IAMRole: (name) => new IAMRoleBuilder(name),
33
+ IAMPolicy: (name) => new IAMPolicyBuilder(name),
34
+ SNS: (name) => new SNSTopicBuilder(name),
35
+ Alarm: (name) => new CloudWatchAlarmBuilder(name),
29
36
  };
30
37
  export * from "../../types/aws.js";
@@ -1,5 +1,6 @@
1
1
  import { BaseBuilder } from "../../core/resource.js";
2
2
  import { SecretsBuilder } from "./secrets.js";
3
+ import { IAMRoleBuilder } from "./iam.js";
3
4
  export declare class LambdaBuilder extends BaseBuilder {
4
5
  private _runtime;
5
6
  private _handler;
@@ -8,6 +9,7 @@ export declare class LambdaBuilder extends BaseBuilder {
8
9
  private _codePath?;
9
10
  private _env;
10
11
  private _roleArn?;
12
+ private _roleBuilder?;
11
13
  resolvedArn: string | null;
12
14
  constructor(name: string);
13
15
  private discoverFunction;
@@ -16,7 +18,7 @@ export declare class LambdaBuilder extends BaseBuilder {
16
18
  handler(h: string): this;
17
19
  memory(mb: number): this;
18
20
  timeout(seconds: number): this;
19
- role(arn: string): this;
21
+ role(arnOrBuilder: string | IAMRoleBuilder): this;
20
22
  env(vars: Record<string, string | SecretsBuilder>): this;
21
23
  private ensureRole;
22
24
  private buildZip;
@@ -25,6 +25,7 @@ export class LambdaBuilder extends BaseBuilder {
25
25
  _codePath;
26
26
  _env = {};
27
27
  _roleArn;
28
+ _roleBuilder;
28
29
  resolvedArn = null;
29
30
  constructor(name) {
30
31
  super(name);
@@ -64,8 +65,13 @@ export class LambdaBuilder extends BaseBuilder {
64
65
  this._timeout = seconds;
65
66
  return this;
66
67
  }
67
- role(arn) {
68
- this._roleArn = arn;
68
+ role(arnOrBuilder) {
69
+ if (typeof arnOrBuilder === "string") {
70
+ this._roleArn = arnOrBuilder;
71
+ }
72
+ else {
73
+ this._roleBuilder = arnOrBuilder;
74
+ }
69
75
  return this;
70
76
  }
71
77
  env(vars) {
@@ -73,6 +79,9 @@ export class LambdaBuilder extends BaseBuilder {
73
79
  return this;
74
80
  }
75
81
  async ensureRole() {
82
+ if (this._roleBuilder) {
83
+ return await this._roleBuilder.out.arn.get();
84
+ }
76
85
  if (this._roleArn)
77
86
  return this._roleArn;
78
87
  const roleName = `puls-lambda-${this.name}-role`;
@@ -14,6 +14,7 @@ export declare class RDSBuilder extends BaseBuilder {
14
14
  resolvedPort: number | null;
15
15
  resolvedArn: string | null;
16
16
  constructor(name: string);
17
+ get dbInstanceIdentifier(): string;
17
18
  engine(e: {
18
19
  engine: string;
19
20
  version: string;
@@ -26,6 +26,9 @@ export class RDSBuilder extends BaseBuilder {
26
26
  super(name);
27
27
  this.discoveryPromise = this.discoverInstance(name);
28
28
  }
29
+ get dbInstanceIdentifier() {
30
+ return this.name;
31
+ }
29
32
  engine(e) {
30
33
  this._engine = e.engine;
31
34
  this._engineVersion = e.version;
@@ -74,7 +77,7 @@ export class RDSBuilder extends BaseBuilder {
74
77
  return instance;
75
78
  }
76
79
  catch (e) {
77
- if (e.name === "DBInstanceNotFound")
80
+ if (e.name === "DBInstanceNotFound" || e.name === "DBInstanceNotFoundFault")
78
81
  return null;
79
82
  if (e.name === "CredentialsProviderError")
80
83
  return null;
@@ -0,0 +1,22 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ import { Output } from "../../core/output.js";
3
+ export declare class SNSTopicBuilder extends BaseBuilder {
4
+ readonly out: {
5
+ arn: Output<string>;
6
+ };
7
+ private _displayName?;
8
+ private _subscriptions;
9
+ resolvedArn: string | null;
10
+ resolvedDisplayName: string | null;
11
+ constructor(name: string);
12
+ displayName(name: string): this;
13
+ subscribe(protocol: "email" | "sms" | "lambda" | "sqs" | "https", endpoint: string): this;
14
+ private discoverTopic;
15
+ deploy(): Promise<{
16
+ name: string;
17
+ arn: string | null;
18
+ }>;
19
+ destroy(): Promise<{
20
+ destroyed: string;
21
+ }>;
22
+ }
@@ -0,0 +1,146 @@
1
+ import { CreateTopicCommand, DeleteTopicCommand, GetTopicAttributesCommand, ListTopicsCommand, SetTopicAttributesCommand, SubscribeCommand, UnsubscribeCommand, ListSubscriptionsByTopicCommand, } from "@aws-sdk/client-sns";
2
+ import { BaseBuilder } from "../../core/resource.js";
3
+ import { Output } from "../../core/output.js";
4
+ import { getSNSClient } from "./api.js";
5
+ export class SNSTopicBuilder extends BaseBuilder {
6
+ out = {
7
+ arn: new Output(),
8
+ };
9
+ _displayName;
10
+ _subscriptions = [];
11
+ resolvedArn = null;
12
+ resolvedDisplayName = null;
13
+ constructor(name) {
14
+ super(name);
15
+ this.discoveryPromise = this.discoverTopic(name);
16
+ }
17
+ displayName(name) {
18
+ this._displayName = name;
19
+ return this;
20
+ }
21
+ subscribe(protocol, endpoint) {
22
+ this._subscriptions.push({ protocol, endpoint });
23
+ return this;
24
+ }
25
+ async discoverTopic(name) {
26
+ const sns = getSNSClient();
27
+ try {
28
+ let nextToken;
29
+ do {
30
+ const result = await sns.send(new ListTopicsCommand({ NextToken: nextToken }));
31
+ const match = (result.Topics ?? []).find((t) => t.TopicArn?.split(":").pop() === name);
32
+ if (match) {
33
+ this.resolvedArn = match.TopicArn ?? null;
34
+ if (this.resolvedArn) {
35
+ this.out.arn.resolve(this.resolvedArn);
36
+ try {
37
+ const attrsResult = await sns.send(new GetTopicAttributesCommand({ TopicArn: this.resolvedArn }));
38
+ this.resolvedDisplayName = attrsResult.Attributes?.DisplayName ?? null;
39
+ }
40
+ catch (err) {
41
+ // Ignore attribute fetch errors (e.g. permission or not found)
42
+ }
43
+ }
44
+ return match;
45
+ }
46
+ nextToken = result.NextToken;
47
+ } while (nextToken);
48
+ return null;
49
+ }
50
+ catch (e) {
51
+ if (e.name === "CredentialsProviderError")
52
+ return null;
53
+ throw e;
54
+ }
55
+ }
56
+ async deploy() {
57
+ const dryRun = this.isDryRunActive();
58
+ const existing = await this.discoveryPromise;
59
+ const sns = getSNSClient();
60
+ console.log(`\n📢 Finalizing SNS Topic "${this.name}"...`);
61
+ if (dryRun) {
62
+ console.log(` 📝 [PLAN] ${existing ? "Update" : "Create"} SNS topic "${this.name}"`);
63
+ if (this._displayName) {
64
+ console.log(` └─ Display Name: ${this._displayName}`);
65
+ }
66
+ for (const sub of this._subscriptions) {
67
+ console.log(` └─ Subscribe: ${sub.protocol} to ${sub.endpoint}`);
68
+ }
69
+ this.resolvedArn = existing?.TopicArn ?? `arn:aws:sns:us-east-1:000000000000:DRYRUN-${this.name}`;
70
+ this.out.arn.resolve(this.resolvedArn);
71
+ return { name: this.name, arn: this.resolvedArn };
72
+ }
73
+ const topicAttrs = {};
74
+ if (this._displayName) {
75
+ topicAttrs.DisplayName = this._displayName;
76
+ }
77
+ if (!existing) {
78
+ const result = await sns.send(new CreateTopicCommand({
79
+ Name: this.name,
80
+ Attributes: topicAttrs,
81
+ }));
82
+ this.resolvedArn = result.TopicArn;
83
+ this.out.arn.resolve(this.resolvedArn);
84
+ console.log(`🚀 Created SNS Topic "${this.name}" (arn=${this.resolvedArn})`);
85
+ }
86
+ else {
87
+ this.resolvedArn = existing.TopicArn;
88
+ this.out.arn.resolve(this.resolvedArn);
89
+ if (this._displayName && this._displayName !== this.resolvedDisplayName) {
90
+ await sns.send(new SetTopicAttributesCommand({
91
+ TopicArn: this.resolvedArn,
92
+ AttributeName: "DisplayName",
93
+ AttributeValue: this._displayName,
94
+ }));
95
+ console.log(` ✅ Updated SNS topic display name to "${this._displayName}"`);
96
+ }
97
+ else {
98
+ console.log(` ✅ SNS topic "${this.name}" already exists`);
99
+ }
100
+ }
101
+ // Sync subscriptions
102
+ const activeSubsResult = await sns.send(new ListSubscriptionsByTopicCommand({ TopicArn: this.resolvedArn }));
103
+ const activeSubs = activeSubsResult.Subscriptions ?? [];
104
+ // 1. Unsubscribe stale subscriptions
105
+ for (const sub of activeSubs) {
106
+ if (!sub.SubscriptionArn || sub.SubscriptionArn === "PendingConfirmation")
107
+ continue;
108
+ const isStillWanted = this._subscriptions.some((s) => s.protocol === sub.Protocol && s.endpoint === sub.Endpoint);
109
+ if (!isStillWanted) {
110
+ await sns.send(new UnsubscribeCommand({ SubscriptionArn: sub.SubscriptionArn }));
111
+ console.log(` 🧹 Unsubscribed stale subscription: ${sub.Protocol} to ${sub.Endpoint}`);
112
+ }
113
+ }
114
+ // 2. Subscribe new subscriptions
115
+ for (const target of this._subscriptions) {
116
+ const alreadyExists = activeSubs.some((sub) => sub.Protocol === target.protocol && sub.Endpoint === target.endpoint);
117
+ if (!alreadyExists) {
118
+ await sns.send(new SubscribeCommand({
119
+ TopicArn: this.resolvedArn,
120
+ Protocol: target.protocol,
121
+ Endpoint: target.endpoint,
122
+ }));
123
+ console.log(` ➕ Subscribed: ${target.protocol} to ${target.endpoint}`);
124
+ }
125
+ }
126
+ await this.deploySidecars();
127
+ return { name: this.name, arn: this.resolvedArn };
128
+ }
129
+ async destroy() {
130
+ const dryRun = this.isDryRunActive();
131
+ const existing = await this.discoveryPromise;
132
+ console.log(`\n🗑️ Destroying SNS Topic "${this.name}"...`);
133
+ if (!existing) {
134
+ console.log(` ✅ Topic "${this.name}" does not exist - nothing to do`);
135
+ return { destroyed: this.name };
136
+ }
137
+ if (dryRun) {
138
+ console.log(` 📝 [PLAN] Delete SNS Topic "${this.name}"`);
139
+ return { destroyed: this.name };
140
+ }
141
+ const sns = getSNSClient();
142
+ await sns.send(new DeleteTopicCommand({ TopicArn: this.resolvedArn }));
143
+ console.log(` ✅ Deleted SNS Topic "${this.name}"`);
144
+ return { destroyed: this.name };
145
+ }
146
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,162 @@
1
+ import { test, describe, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert";
3
+ import { SNSClient } from "@aws-sdk/client-sns";
4
+ import { SNSTopicBuilder } from "./sns.js";
5
+ import { Config } from "../../core/config.js";
6
+ describe("SNSTopicBuilder Unit Tests", () => {
7
+ let originalSnsSend;
8
+ let snsCalls = [];
9
+ let mockSnsResponses = {};
10
+ beforeEach(() => {
11
+ Config.set({
12
+ dryRun: false,
13
+ providers: {
14
+ aws: { region: "us-east-1" },
15
+ },
16
+ });
17
+ snsCalls = [];
18
+ mockSnsResponses = {};
19
+ originalSnsSend = SNSClient.prototype.send;
20
+ SNSClient.prototype.send = async function (command) {
21
+ const commandName = command.constructor.name;
22
+ const input = command.input;
23
+ snsCalls.push({ commandName, input });
24
+ if (mockSnsResponses[commandName]) {
25
+ const handler = mockSnsResponses[commandName];
26
+ if (typeof handler === "function")
27
+ return handler(input);
28
+ if (handler instanceof Error)
29
+ throw handler;
30
+ return handler;
31
+ }
32
+ return {};
33
+ };
34
+ });
35
+ afterEach(() => {
36
+ SNSClient.prototype.send = originalSnsSend;
37
+ });
38
+ test("gracefully handles discovery when topic does not exist", async () => {
39
+ mockSnsResponses["ListTopicsCommand"] = { Topics: [] };
40
+ const builder = new SNSTopicBuilder("my-topic");
41
+ const discoveryResult = await builder.discoveryPromise;
42
+ assert.strictEqual(discoveryResult, null);
43
+ assert.ok(snsCalls.some((c) => c.commandName === "ListTopicsCommand"));
44
+ });
45
+ test("discovers existing topic by matching name", async () => {
46
+ mockSnsResponses["ListTopicsCommand"] = {
47
+ Topics: [{ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic" }],
48
+ };
49
+ mockSnsResponses["GetTopicAttributesCommand"] = {
50
+ Attributes: { DisplayName: "My Friendly Topic" },
51
+ };
52
+ const builder = new SNSTopicBuilder("my-topic");
53
+ const discoveryResult = await builder.discoveryPromise;
54
+ assert.ok(discoveryResult);
55
+ assert.strictEqual(builder.resolvedArn, "arn:aws:sns:us-east-1:123456789012:my-topic");
56
+ assert.strictEqual(builder.resolvedDisplayName, "My Friendly Topic");
57
+ const resolvedArn = await builder.out.arn.get();
58
+ assert.strictEqual(resolvedArn, "arn:aws:sns:us-east-1:123456789012:my-topic");
59
+ });
60
+ test("creates a new topic with display name and subscriptions", async () => {
61
+ mockSnsResponses["ListTopicsCommand"] = { Topics: [] };
62
+ mockSnsResponses["CreateTopicCommand"] = {
63
+ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
64
+ };
65
+ mockSnsResponses["ListSubscriptionsByTopicCommand"] = { Subscriptions: [] };
66
+ const builder = new SNSTopicBuilder("my-topic")
67
+ .displayName("Cool Alert")
68
+ .subscribe("email", "ops@company.com")
69
+ .subscribe("sms", "+15555555555");
70
+ const deployResult = await builder.deploy();
71
+ assert.strictEqual(deployResult.arn, "arn:aws:sns:us-east-1:123456789012:my-topic");
72
+ assert.strictEqual(builder.resolvedArn, "arn:aws:sns:us-east-1:123456789012:my-topic");
73
+ const createCall = snsCalls.find((c) => c.commandName === "CreateTopicCommand");
74
+ assert.ok(createCall);
75
+ assert.deepStrictEqual(createCall.input, {
76
+ Name: "my-topic",
77
+ Attributes: { DisplayName: "Cool Alert" },
78
+ });
79
+ const subscribeCalls = snsCalls.filter((c) => c.commandName === "SubscribeCommand");
80
+ assert.strictEqual(subscribeCalls.length, 2);
81
+ assert.deepStrictEqual(subscribeCalls[0].input, {
82
+ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
83
+ Protocol: "email",
84
+ Endpoint: "ops@company.com",
85
+ });
86
+ assert.deepStrictEqual(subscribeCalls[1].input, {
87
+ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
88
+ Protocol: "sms",
89
+ Endpoint: "+15555555555",
90
+ });
91
+ });
92
+ test("syncs subscriptions correctly - unsubscribes stale and skips active", async () => {
93
+ mockSnsResponses["ListTopicsCommand"] = {
94
+ Topics: [{ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic" }],
95
+ };
96
+ mockSnsResponses["GetTopicAttributesCommand"] = {
97
+ Attributes: { DisplayName: "Cool Alert" },
98
+ };
99
+ mockSnsResponses["ListSubscriptionsByTopicCommand"] = {
100
+ Subscriptions: [
101
+ {
102
+ SubscriptionArn: "arn:aws:sns:us-east-1:123456789012:my-topic:sub1",
103
+ Protocol: "email",
104
+ Endpoint: "keep-me@company.com",
105
+ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
106
+ },
107
+ {
108
+ SubscriptionArn: "arn:aws:sns:us-east-1:123456789012:my-topic:sub2",
109
+ Protocol: "email",
110
+ Endpoint: "delete-me@company.com",
111
+ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
112
+ },
113
+ ],
114
+ };
115
+ const builder = new SNSTopicBuilder("my-topic")
116
+ .displayName("Cool Alert")
117
+ .subscribe("email", "keep-me@company.com")
118
+ .subscribe("sms", "+19999999999");
119
+ await builder.deploy();
120
+ // Verify unsubscribe was called for stale one
121
+ const unsubscribeCall = snsCalls.find((c) => c.commandName === "UnsubscribeCommand");
122
+ assert.ok(unsubscribeCall);
123
+ assert.strictEqual(unsubscribeCall.input.SubscriptionArn, "arn:aws:sns:us-east-1:123456789012:my-topic:sub2");
124
+ // Verify subscribe was called for the new sms one, but NOT for keep-me@company.com
125
+ const subscribeCalls = snsCalls.filter((c) => c.commandName === "SubscribeCommand");
126
+ assert.strictEqual(subscribeCalls.length, 1);
127
+ assert.deepStrictEqual(subscribeCalls[0].input, {
128
+ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
129
+ Protocol: "sms",
130
+ Endpoint: "+19999999999",
131
+ });
132
+ });
133
+ test("destroys an existing topic successfully", async () => {
134
+ mockSnsResponses["ListTopicsCommand"] = {
135
+ Topics: [{ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic" }],
136
+ };
137
+ const builder = new SNSTopicBuilder("my-topic");
138
+ await builder.discoveryPromise;
139
+ const destroyResult = await builder.destroy();
140
+ assert.deepStrictEqual(destroyResult, { destroyed: "my-topic" });
141
+ const deleteCall = snsCalls.find((c) => c.commandName === "DeleteTopicCommand");
142
+ assert.ok(deleteCall);
143
+ assert.strictEqual(deleteCall.input.TopicArn, "arn:aws:sns:us-east-1:123456789012:my-topic");
144
+ });
145
+ test("runs in dry run mode safely", async () => {
146
+ Config.set({
147
+ dryRun: true,
148
+ providers: {
149
+ aws: { region: "us-east-1" },
150
+ },
151
+ });
152
+ mockSnsResponses["ListTopicsCommand"] = { Topics: [] };
153
+ const builder = new SNSTopicBuilder("my-topic")
154
+ .displayName("Cool Alert")
155
+ .subscribe("email", "ops@company.com");
156
+ const deployResult = await builder.deploy();
157
+ assert.ok(deployResult.arn.includes("DRYRUN"));
158
+ // No create topic or subscribe commands should be called in real mode
159
+ assert.ok(!snsCalls.some((c) => c.commandName === "CreateTopicCommand"));
160
+ assert.ok(!snsCalls.some((c) => c.commandName === "SubscribeCommand"));
161
+ });
162
+ });
@@ -19,6 +19,7 @@ export declare class VMBuilder extends BaseBuilder {
19
19
  private _vlan?;
20
20
  private _ip?;
21
21
  private _sshKeys?;
22
+ private _machine;
22
23
  constructor(name: string);
23
24
  private discoverVm;
24
25
  image(os: OSImage): this;
@@ -31,6 +32,7 @@ export declare class VMBuilder extends BaseBuilder {
31
32
  vlan(tag: number): this;
32
33
  ip(address: string): this;
33
34
  sshKey(keys: string | readonly string[]): this;
35
+ machine(type: "q35" | "i440fx"): this;
34
36
  deploy(): Promise<{
35
37
  name: string;
36
38
  vmid: number | null;
@@ -24,6 +24,7 @@ export class VMBuilder extends BaseBuilder {
24
24
  _vlan;
25
25
  _ip;
26
26
  _sshKeys;
27
+ _machine = "q35";
27
28
  constructor(name) {
28
29
  super(name);
29
30
  this.discoveryPromise = this.discoverVm(name);
@@ -80,6 +81,10 @@ export class VMBuilder extends BaseBuilder {
80
81
  this._sshKeys = Array.isArray(keys) ? [...keys] : keys;
81
82
  return this;
82
83
  }
84
+ machine(type) {
85
+ this._machine = type;
86
+ return this;
87
+ }
83
88
  async deploy() {
84
89
  const dryRun = this.isDryRunActive();
85
90
  const existing = await this.discoveryPromise;
@@ -102,7 +107,7 @@ export class VMBuilder extends BaseBuilder {
102
107
  console.log(` 📝 [PLAN] Create VM "${this.name}"`);
103
108
  if (this._image)
104
109
  console.log(` └─ Image: ${this._image}`);
105
- console.log(` └─ Cores: ${this._cores} Memory: ${this._memory} MB`);
110
+ console.log(` └─ Cores: ${this._cores} Memory: ${this._memory} MB Machine: ${this._machine}`);
106
111
  if (this._vlan)
107
112
  console.log(` └─ VLAN: ${this._vlan}`);
108
113
  if (this._provision) {
@@ -132,8 +137,35 @@ export class VMBuilder extends BaseBuilder {
132
137
  ? `Check that VMID ${this._image} exists and is marked as a template.`
133
138
  : `Create a template whose name contains "${this._image}".`));
134
139
  }
135
- // Resolve target node: explicit → configured nodes list → template's node → API discovery
140
+ // Resolve target node: explicit → cluster-aware (online & max free RAM) → configured nodes list → template's node → API discovery
136
141
  let node = this._node;
142
+ if (!node) {
143
+ try {
144
+ const nodesList = await pm.get("/nodes");
145
+ const configuredNodes = Config.get().providers.proxmox?.nodes;
146
+ const onlineNodes = (nodesList ?? []).filter((n) => {
147
+ if (n.status !== "online")
148
+ return false;
149
+ if (configuredNodes && configuredNodes.length > 0) {
150
+ return configuredNodes.includes(n.node);
151
+ }
152
+ return true;
153
+ });
154
+ if (onlineNodes.length > 0) {
155
+ // Sort descending by free memory (maxmem - mem)
156
+ onlineNodes.sort((a, b) => {
157
+ const freeA = (a.maxmem ?? 0) - (a.mem ?? 0);
158
+ const freeB = (b.maxmem ?? 0) - (b.mem ?? 0);
159
+ return freeB - freeA;
160
+ });
161
+ node = onlineNodes[0].node;
162
+ console.log(` 🧠 Cluster-aware node selection: picked "${node}" with the most free RAM (${Math.round((((onlineNodes[0].maxmem ?? 0) - (onlineNodes[0].mem ?? 0)) / 1024 / 1024 / 1024) * 10) / 10} GB free)`);
163
+ }
164
+ }
165
+ catch (err) {
166
+ // Fallback silently to configured nodes list or discovery
167
+ }
168
+ }
137
169
  if (!node) {
138
170
  const configuredNodes = Config.get().providers.proxmox?.nodes;
139
171
  node = configuredNodes?.[0] ?? template?.node;
@@ -191,7 +223,7 @@ export class VMBuilder extends BaseBuilder {
191
223
  const net0 = `virtio,bridge=vmbr1${this._vlan ? `,tag=${this._vlan}` : ""}`;
192
224
  const configPatch = {
193
225
  onboot: 1,
194
- machine: "q35",
226
+ machine: this._machine,
195
227
  cores: this._cores,
196
228
  memory: this._memory,
197
229
  net0,
@@ -0,0 +1 @@
1
+ export {};