@wopr-network/platform-core 1.37.0 → 1.39.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.
@@ -2,7 +2,7 @@ import type Docker from "dockerode";
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import type { NetworkPolicy } from "../network/network-policy.js";
4
4
  import type { IBotInstanceRepository } from "./bot-instance-repository.js";
5
- import { BotNotFoundError, FleetManager, InvalidStateTransitionError } from "./fleet-manager.js";
5
+ import { BotNotFoundError, FleetManager } from "./fleet-manager.js";
6
6
  import type { INodeCommandBus } from "./node-command-bus.js";
7
7
  import type { ProfileStore } from "./profile-store.js";
8
8
  import type { BotInstance, NewBotInstance } from "./repository-types.js";
@@ -19,6 +19,7 @@ function mockContainer(overrides: Record<string, unknown> = {}) {
19
19
  remove: vi.fn().mockResolvedValue(undefined),
20
20
  inspect: vi.fn().mockResolvedValue({
21
21
  Id: "container-123",
22
+ Name: "/wopr-test-bot",
22
23
  Created: "2026-01-01T00:00:00Z",
23
24
  State: {
24
25
  Status: "running",
@@ -26,6 +27,7 @@ function mockContainer(overrides: Record<string, unknown> = {}) {
26
27
  StartedAt: "2026-01-01T00:00:00Z",
27
28
  Health: { Status: "healthy" },
28
29
  },
30
+ NetworkSettings: { Ports: {} },
29
31
  }),
30
32
  stats: vi.fn().mockResolvedValue({
31
33
  cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },
@@ -108,10 +110,10 @@ describe("FleetManager", () => {
108
110
 
109
111
  describe("create", () => {
110
112
  it("saves profile, pulls image, and creates container", async () => {
111
- const profile = await fleet.create(PROFILE_PARAMS);
113
+ const instance = await fleet.create(PROFILE_PARAMS);
112
114
 
113
- expect(profile.id).toEqual(expect.any(String));
114
- expect(profile.name).toBe("test-bot");
115
+ expect(instance.id).toEqual(expect.any(String));
116
+ expect(instance.profile.name).toBe("test-bot");
115
117
  expect(store.save).toHaveBeenCalledWith(expect.objectContaining({ name: "test-bot" }));
116
118
  expect(docker.pull).toHaveBeenCalledWith("ghcr.io/wopr-network/wopr:stable", {});
117
119
  expect(docker.createContainer).toHaveBeenCalledWith(
@@ -140,94 +142,19 @@ describe("FleetManager", () => {
140
142
  });
141
143
 
142
144
  describe("start", () => {
143
- it("starts an existing stopped container", async () => {
144
- // Container must be in a stopped/created/exited state
145
- container.inspect.mockResolvedValue({
146
- Id: "container-123",
147
- Created: "2026-01-01T00:00:00Z",
148
- State: { Status: "stopped", Running: false, StartedAt: "", Health: { Status: "healthy" } },
149
- });
150
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
151
-
152
- await fleet.start("bot-id");
153
- expect(container.start).toHaveBeenCalled();
154
- });
155
-
156
- it("starts an exited container", async () => {
157
- container.inspect.mockResolvedValue({
158
- Id: "container-123",
159
- Created: "2026-01-01T00:00:00Z",
160
- State: { Status: "exited", Running: false, StartedAt: "", Health: null },
161
- });
162
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
163
-
164
- await fleet.start("bot-id");
145
+ it("starts a created container via Instance.start()", async () => {
146
+ const instance = await fleet.create(PROFILE_PARAMS);
147
+ await instance.start();
165
148
  expect(container.start).toHaveBeenCalled();
166
149
  });
167
-
168
- it("starts a dead container", async () => {
169
- container.inspect.mockResolvedValue({
170
- Id: "container-123",
171
- Created: "2026-01-01T00:00:00Z",
172
- State: { Status: "dead", Running: false, StartedAt: "", Health: null },
173
- });
174
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
175
-
176
- await fleet.start("bot-id");
177
- expect(container.start).toHaveBeenCalled();
178
- });
179
-
180
- it("throws InvalidStateTransitionError with 'unknown' when Status is missing/null", async () => {
181
- container.inspect.mockResolvedValue({
182
- Id: "container-123",
183
- Created: "2026-01-01T00:00:00Z",
184
- State: { Status: null, Running: false, StartedAt: "", Health: null },
185
- });
186
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
187
-
188
- await expect(fleet.start("bot-id")).rejects.toThrow(InvalidStateTransitionError);
189
- await expect(fleet.start("bot-id")).rejects.toThrow(/unknown/);
190
- });
191
-
192
- it("throws InvalidStateTransitionError when container is already running", async () => {
193
- // Default mockContainer has state "running"
194
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
195
-
196
- await expect(fleet.start("bot-id")).rejects.toThrow(InvalidStateTransitionError);
197
- await expect(fleet.start("bot-id")).rejects.toThrow(/Cannot start bot/);
198
- });
199
-
200
- it("throws BotNotFoundError when container not found", async () => {
201
- docker.listContainers.mockResolvedValue([]);
202
- await expect(fleet.start("missing")).rejects.toThrow(BotNotFoundError);
203
- });
204
150
  });
205
151
 
206
152
  describe("stop", () => {
207
- it("stops a running container", async () => {
208
- // Default mockContainer has state "running" — valid for stop
209
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
210
-
211
- await fleet.stop("bot-id");
153
+ it("stops a running container via Instance.stop()", async () => {
154
+ const instance = await fleet.create(PROFILE_PARAMS);
155
+ await instance.stop();
212
156
  expect(container.stop).toHaveBeenCalled();
213
157
  });
214
-
215
- it("throws InvalidStateTransitionError when container is already stopped", async () => {
216
- container.inspect.mockResolvedValue({
217
- Id: "container-123",
218
- Created: "2026-01-01T00:00:00Z",
219
- State: { Status: "exited", Running: false, StartedAt: "", Health: null },
220
- });
221
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
222
-
223
- await expect(fleet.stop("bot-id")).rejects.toThrow(InvalidStateTransitionError);
224
- await expect(fleet.stop("bot-id")).rejects.toThrow(/Cannot stop bot/);
225
- });
226
-
227
- it("throws BotNotFoundError when container not found", async () => {
228
- docker.listContainers.mockResolvedValue([]);
229
- await expect(fleet.stop("missing")).rejects.toThrow(BotNotFoundError);
230
- });
231
158
  });
232
159
 
233
160
  describe("restart", () => {
@@ -521,10 +448,11 @@ describe("FleetManager", () => {
521
448
  );
522
449
  });
523
450
 
524
- it("calls addRoute on create with correct subdomain and upstream", async () => {
525
- const profile = await proxyFleet.create(PROFILE_PARAMS);
451
+ it("calls addRoute via setupProxy with correct subdomain and upstream", async () => {
452
+ const instance = await proxyFleet.create(PROFILE_PARAMS);
453
+ await instance.setupProxy();
526
454
  expect(proxyManager.addRoute).toHaveBeenCalledWith({
527
- instanceId: profile.id,
455
+ instanceId: instance.id,
528
456
  subdomain: "test-bot",
529
457
  upstreamHost: "wopr-test-bot",
530
458
  upstreamPort: 7437,
@@ -532,11 +460,12 @@ describe("FleetManager", () => {
532
460
  });
533
461
  });
534
462
 
535
- it("still returns profile when addRoute fails (non-fatal)", async () => {
463
+ it("still returns instance when setupProxy addRoute fails (non-fatal)", async () => {
464
+ const instance = await proxyFleet.create(PROFILE_PARAMS);
536
465
  proxyManager.addRoute.mockRejectedValueOnce(new Error("DNS fail"));
537
- const profile = await proxyFleet.create(PROFILE_PARAMS);
538
- expect(profile.id).toEqual(expect.any(String));
539
- expect(profile.name).toBe("test-bot");
466
+ await instance.setupProxy();
467
+ expect(instance.id).toEqual(expect.any(String));
468
+ expect(instance.profile.name).toBe("test-bot");
540
469
  });
541
470
 
542
471
  it("calls removeRoute on remove", async () => {
@@ -546,36 +475,33 @@ describe("FleetManager", () => {
546
475
  expect(proxyManager.removeRoute).toHaveBeenCalledWith("bot-id");
547
476
  });
548
477
 
549
- it("calls updateHealth(true) on start", async () => {
550
- container.inspect.mockResolvedValue({
551
- Id: "container-123",
552
- Created: "2026-01-01T00:00:00Z",
553
- State: { Status: "stopped", Running: false, StartedAt: "", Health: { Status: "healthy" } },
554
- });
555
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
556
- await proxyFleet.start("bot-id");
557
- expect(proxyManager.updateHealth).toHaveBeenCalledWith("bot-id", true);
478
+ it("starts via Instance.start()", async () => {
479
+ const instance = await proxyFleet.create(PROFILE_PARAMS);
480
+ await instance.start();
481
+ expect(container.start).toHaveBeenCalled();
558
482
  });
559
483
 
560
- it("calls updateHealth(false) on stop", async () => {
561
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
562
- await proxyFleet.stop("bot-id");
563
- expect(proxyManager.updateHealth).toHaveBeenCalledWith("bot-id", false);
484
+ it("stops via Instance.stop()", async () => {
485
+ const instance = await proxyFleet.create(PROFILE_PARAMS);
486
+ await instance.stop();
487
+ expect(container.stop).toHaveBeenCalled();
564
488
  });
565
489
 
566
490
  it("does not call proxy methods when no proxyManager is provided", async () => {
567
491
  // `fleet` from the outer beforeEach has no proxyManager
568
- const profile = await fleet.create(PROFILE_PARAMS);
492
+ const instance = await fleet.create(PROFILE_PARAMS);
493
+ await instance.setupProxy(); // no-op since no proxyManager
569
494
  expect(proxyManager.addRoute).not.toHaveBeenCalled();
570
- expect(profile.id).toEqual(expect.any(String));
495
+ expect(instance.id).toEqual(expect.any(String));
571
496
  });
572
497
 
573
498
  it("normalizes underscores to hyphens in subdomain", async () => {
574
- await proxyFleet.create({ ...PROFILE_PARAMS, name: "my_cool_bot" });
499
+ const instance = await proxyFleet.create({ ...PROFILE_PARAMS, name: "my_cool_bot" });
500
+ await instance.setupProxy();
575
501
  expect(proxyManager.addRoute).toHaveBeenCalledWith(
576
502
  expect.objectContaining({
577
503
  subdomain: "my-cool-bot",
578
- upstreamHost: "wopr-my-cool-bot",
504
+ upstreamHost: "wopr-test-bot",
579
505
  }),
580
506
  );
581
507
  });
@@ -646,76 +572,6 @@ describe("FleetManager", () => {
646
572
  });
647
573
 
648
574
  describe("per-bot mutex", () => {
649
- it("serializes 5 concurrent startBot calls into exactly 1 start per call", async () => {
650
- // Containers must be in stopped state so the state guard passes
651
- container.inspect.mockResolvedValue({
652
- Id: "container-123",
653
- Created: "2026-01-01T00:00:00Z",
654
- State: { Status: "stopped", Running: false, StartedAt: "", Health: null },
655
- });
656
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
657
-
658
- let startCount = 0;
659
- container.start.mockImplementation(async () => {
660
- startCount++;
661
- const current = startCount;
662
- await new Promise((r) => setTimeout(r, 10));
663
- expect(startCount).toBe(current);
664
- });
665
-
666
- const promises = Array.from({ length: 5 }, () => fleet.start("bot-id"));
667
- await Promise.all(promises);
668
-
669
- expect(container.start).toHaveBeenCalledTimes(5);
670
- });
671
-
672
- it("does not block operations on different bots (proves concurrency)", async () => {
673
- // For this test: start needs stopped state, stop needs running state.
674
- // Both bot-a and bot-b share the same mock container.
675
- // Use a stopped state so start() passes, and mock stop() directly so it bypasses state check.
676
- container.inspect.mockResolvedValue({
677
- Id: "container-123",
678
- Created: "2026-01-01T00:00:00Z",
679
- State: { Status: "stopped", Running: false, StartedAt: "", Health: null },
680
- });
681
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
682
-
683
- let releaseStart!: () => void;
684
- container.start.mockImplementation(
685
- () =>
686
- new Promise<void>((resolve) => {
687
- releaseStart = resolve;
688
- }),
689
- );
690
- const stopSpy = vi.fn();
691
- // Override inspect for the stop path to return "running" state
692
- let callCount = 0;
693
- container.inspect.mockImplementation(async () => {
694
- callCount++;
695
- // First call is for bot-a start, subsequent for bot-b stop
696
- return callCount === 1
697
- ? { Id: "container-123", State: { Status: "stopped", Running: false } }
698
- : { Id: "container-123", State: { Status: "running", Running: true } };
699
- });
700
- container.stop.mockImplementation(async () => {
701
- stopSpy();
702
- });
703
-
704
- const startPromise = fleet.start("bot-a");
705
- await Promise.resolve(); // allow start lock acquisition
706
- await expect(fleet.stop("bot-b")).resolves.toBeUndefined();
707
- expect(stopSpy).toHaveBeenCalledTimes(1);
708
- releaseStart();
709
- await startPromise;
710
- });
711
-
712
- it("releases lock even when operation throws", async () => {
713
- docker.listContainers.mockResolvedValue([]);
714
-
715
- await expect(fleet.start("bot-id")).rejects.toThrow(BotNotFoundError);
716
- await expect(fleet.start("bot-id")).rejects.toThrow(BotNotFoundError);
717
- });
718
-
719
575
  it("serializes concurrent create calls with the same explicit ID (mutual exclusion)", async () => {
720
576
  // Add a delay to store.save so the race is observable: the mutex must ensure
721
577
  // the first save completes before the second call checks for the existing profile.
@@ -800,7 +656,7 @@ describe("FleetManager", () => {
800
656
  instanceRepo,
801
657
  );
802
658
 
803
- const profile = await fleet.create({
659
+ const instance = await fleet.create({
804
660
  id: botId,
805
661
  tenantId: "t1",
806
662
  name: "test-bot",
@@ -812,7 +668,7 @@ describe("FleetManager", () => {
812
668
  updatePolicy: "manual",
813
669
  });
814
670
 
815
- expect(profile.id).toBe(botId);
671
+ expect(instance.id).toBe(botId);
816
672
  expect(bus.send).toHaveBeenCalledWith("node-1", {
817
673
  type: "bot.start",
818
674
  payload: {
@@ -862,45 +718,6 @@ describe("FleetManager", () => {
862
718
  expect(docker.pull).toHaveBeenCalled();
863
719
  });
864
720
 
865
- it("should dispatch stop via commandBus when bot has nodeId", async () => {
866
- const container = mockContainer();
867
- const docker = mockDocker(container);
868
- const store = mockStore();
869
- const bus = mockCommandBus();
870
- const instanceRepo = mockInstanceRepo();
871
-
872
- const botId = "bot-stop-1";
873
- await instanceRepo.create({ id: botId, tenantId: "t1", name: "stop-bot", nodeId: "node-2" });
874
-
875
- const fleet = new FleetManager(
876
- docker as unknown as Docker,
877
- store,
878
- undefined,
879
- undefined,
880
- undefined,
881
- bus,
882
- instanceRepo,
883
- );
884
-
885
- await store.save({
886
- id: botId,
887
- tenantId: "t1",
888
- name: "stop-bot",
889
- description: "",
890
- image: "ghcr.io/wopr-network/wopr:latest",
891
- env: {},
892
- restartPolicy: "unless-stopped",
893
- } as BotProfile);
894
-
895
- await fleet.stop(botId);
896
-
897
- expect(bus.send).toHaveBeenCalledWith("node-2", {
898
- type: "bot.stop",
899
- payload: { name: "stop-bot" },
900
- });
901
- expect(container.stop).not.toHaveBeenCalled();
902
- });
903
-
904
721
  it("should dispatch restart via commandBus when bot has nodeId", async () => {
905
722
  const container = mockContainer();
906
723
  const docker = mockDocker(container);
@@ -10,6 +10,7 @@ import type { NetworkPolicy } from "../network/network-policy.js";
10
10
  import type { ProxyManagerInterface } from "../proxy/types.js";
11
11
  import type { IBotInstanceRepository } from "./bot-instance-repository.js";
12
12
  import type { BotEventType, FleetEventEmitter } from "./fleet-event-emitter.js";
13
+ import { Instance } from "./instance.js";
13
14
  import type { INodeCommandBus } from "./node-command-bus.js";
14
15
  import type { IProfileStore } from "./profile-store.js";
15
16
  import { getSharedVolumeConfig } from "./shared-volume-config.js";
@@ -98,10 +99,10 @@ export class FleetManager {
98
99
  async create(
99
100
  params: Omit<BotProfile, "id"> & { id?: string },
100
101
  resourceLimits?: ContainerResourceLimits,
101
- ): Promise<BotProfile> {
102
+ ): Promise<Instance> {
102
103
  const id = params.id ?? randomUUID();
103
104
  const hasExplicitId = "id" in params && params.id !== undefined;
104
- const doCreate = async () => {
105
+ const doCreate = async (): Promise<Instance> => {
105
106
  const profile: BotProfile = { ...params, id };
106
107
 
107
108
  if (hasExplicitId && (await this.store.get(id))) {
@@ -113,7 +114,6 @@ export class FleetManager {
113
114
  try {
114
115
  const remote = await this.resolveNodeId(id);
115
116
  if (remote) {
116
- // Dispatch to remote node agent — it handles pull + create + start
117
117
  await remote.commandBus.send(remote.nodeId, {
118
118
  type: "bot.start",
119
119
  payload: {
@@ -123,8 +123,21 @@ export class FleetManager {
123
123
  restart: profile.restartPolicy,
124
124
  },
125
125
  });
126
+ // Remote bots have no local container — return a remote Instance
127
+ const containerName = `wopr-${profile.name.replace(/_/g, "-")}`;
128
+ const remoteInstance = new Instance({
129
+ docker: this.docker,
130
+ profile,
131
+ containerId: `remote:${remote.nodeId}`,
132
+ containerName,
133
+ url: `remote://${remote.nodeId}/${containerName}`,
134
+ instanceRepo: this.instanceRepo,
135
+ proxyManager: this.proxyManager,
136
+ eventEmitter: this.eventEmitter,
137
+ });
138
+ remoteInstance.emitCreated();
139
+ return remoteInstance;
126
140
  } else {
127
- // Local dockerode fallback
128
141
  await this.pullImage(profile.image);
129
142
  await this.createContainer(profile, resourceLimits);
130
143
  }
@@ -136,106 +149,60 @@ export class FleetManager {
136
149
  throw err;
137
150
  }
138
151
 
139
- // Register proxy route for tenant subdomain routing (non-fatal)
140
- if (this.proxyManager) {
141
- try {
142
- const subdomain = profile.name.toLowerCase().replace(/_/g, "-");
143
- await this.proxyManager.addRoute({
144
- instanceId: profile.id,
145
- subdomain,
146
- upstreamHost: `wopr-${subdomain}`,
147
- upstreamPort: 7437,
148
- healthy: true,
149
- });
150
- } catch (err) {
151
- logger.warn("Proxy route registration failed (non-fatal)", { botId: profile.id, err });
152
- }
153
- }
154
-
155
- this.emitEvent("bot.created", profile.id, profile.tenantId);
156
- return profile;
152
+ const instance = await this.buildInstance(profile);
153
+ instance.emitCreated();
154
+ return instance;
157
155
  };
158
156
 
159
157
  return hasExplicitId ? this.withLock(id, doCreate) : doCreate();
160
158
  }
161
159
 
162
160
  /**
163
- * Start a stopped bot container.
164
- * Valid from: stopped, created, exited, dead, error states.
165
- * Throws InvalidStateTransitionError if the container is already running.
161
+ * Build an Instance from a profile after container creation.
162
+ * Inspects the Docker container to resolve container name and URL.
166
163
  */
167
- async start(id: string): Promise<void> {
168
- return this.withLock(id, async () => {
169
- this.botMetricsTracker?.reset(id);
170
- const remote = await this.resolveNodeId(id);
171
- if (remote) {
172
- const profile = await this.store.get(id);
173
- if (!profile) throw new BotNotFoundError(id);
174
- await remote.commandBus.send(remote.nodeId, {
175
- type: "bot.start",
176
- payload: {
177
- name: profile.name,
178
- image: profile.image,
179
- env: profile.env,
180
- restart: profile.restartPolicy,
181
- },
182
- });
183
- } else {
184
- const container = await this.findContainer(id);
185
- if (!container) throw new BotNotFoundError(id);
186
- const info = await container.inspect();
187
- const validStartStates = new Set(["stopped", "created", "exited", "dead", "error"]);
188
- this.assertValidState(id, info.State.Status, "start", validStartStates);
189
- await container.start();
190
- }
191
- if (this.proxyManager) {
192
- this.proxyManager.updateHealth(id, true);
193
- }
194
- logger.info(`Started bot ${id}`);
195
- let startedTenantId: string | undefined;
196
- try {
197
- startedTenantId = (await this.store.get(id))?.tenantId;
198
- } catch (err) {
199
- logger.warn(`Failed to fetch profile after starting bot ${id}`, { err });
200
- }
201
- this.emitEvent("bot.started", id, startedTenantId);
202
- });
164
+ private resolvePort(profile: BotProfile): number {
165
+ const envPort = profile.env?.PORT;
166
+ return envPort ? Number.parseInt(envPort, 10) || 7437 : 7437;
203
167
  }
204
168
 
205
169
  /**
206
- * Stop a running bot container.
207
- * Valid from: running, starting, restarting states.
208
- * Throws InvalidStateTransitionError if the container is not running.
170
+ * Get an Instance handle for an existing bot by ID.
171
+ * Looks up the profile and inspects the Docker container.
209
172
  */
210
- async stop(id: string): Promise<void> {
211
- return this.withLock(id, async () => {
212
- const remote = await this.resolveNodeId(id);
213
- if (remote) {
214
- const profile = await this.store.get(id);
215
- if (!profile) throw new BotNotFoundError(id);
216
- await remote.commandBus.send(remote.nodeId, {
217
- type: "bot.stop",
218
- payload: { name: profile.name },
219
- });
220
- } else {
221
- const container = await this.findContainer(id);
222
- if (!container) throw new BotNotFoundError(id);
223
- const info = await container.inspect();
224
- const validStopStates = new Set(["running", "starting", "restarting"]);
225
- this.assertValidState(id, info.State.Status, "stop", validStopStates);
226
- await container.stop();
227
- }
228
- if (this.proxyManager) {
229
- this.proxyManager.updateHealth(id, false);
230
- }
231
- logger.info(`Stopped bot ${id}`);
232
- let stoppedTenantId: string | undefined;
233
- try {
234
- stoppedTenantId = (await this.store.get(id))?.tenantId;
235
- } catch (err) {
236
- logger.warn(`Failed to fetch profile after stopping bot ${id}`, { err });
237
- }
238
- this.emitEvent("bot.stopped", id, stoppedTenantId);
173
+ async getInstance(id: string): Promise<Instance> {
174
+ const profile = await this.store.get(id);
175
+ if (!profile) throw new BotNotFoundError(id);
176
+ return this.buildInstance(profile);
177
+ }
178
+
179
+ private async buildInstance(profile: BotProfile): Promise<Instance> {
180
+ const dockerContainer = await this.findContainer(profile.id);
181
+ if (!dockerContainer) throw new Error(`Container for ${profile.id} not found after creation`);
182
+ const info = await dockerContainer.inspect();
183
+ const containerName = info.Name.replace(/^\//, "");
184
+ const containerId = info.Id;
185
+
186
+ // Resolve URL from network DNS or host port mapping
187
+ let url: string;
188
+ const port = this.resolvePort(profile);
189
+ if (profile.network) {
190
+ url = `http://${containerName}:${port}`;
191
+ } else {
192
+ const portBindings = info.NetworkSettings?.Ports?.[`${port}/tcp`];
193
+ const hostPort = portBindings?.[0]?.HostPort ?? String(port);
194
+ url = `http://localhost:${hostPort}`;
195
+ }
196
+
197
+ return new Instance({
198
+ docker: this.docker,
199
+ profile,
200
+ containerId,
201
+ containerName,
202
+ url,
203
+ instanceRepo: this.instanceRepo,
204
+ proxyManager: this.proxyManager,
205
+ eventEmitter: this.eventEmitter,
239
206
  });
240
207
  }
241
208
 
@@ -433,6 +400,8 @@ export class FleetManager {
433
400
  "volumeName",
434
401
  "name",
435
402
  "discovery",
403
+ "network",
404
+ "ephemeral",
436
405
  ]);
437
406
 
438
407
  /**
@@ -608,23 +577,29 @@ export class FleetManager {
608
577
  binds.push(`${sharedVolConfig.volumeName}:${sharedVolConfig.mountPath}:ro`);
609
578
  }
610
579
 
580
+ const isEphemeral = profile.ephemeral === true;
581
+
611
582
  const hostConfig: Docker.ContainerCreateOptions["HostConfig"] = {
612
583
  RestartPolicy: {
613
584
  Name: restartPolicyMap[profile.restartPolicy] || "",
614
585
  },
615
586
  Binds: binds.length > 0 ? binds : undefined,
616
587
  SecurityOpt: ["no-new-privileges"],
617
- CapDrop: ["ALL"],
618
- CapAdd: ["NET_BIND_SERVICE"],
619
- ReadonlyRootfs: true,
620
- Tmpfs: {
621
- "/tmp": "rw,noexec,nosuid,size=64m",
622
- "/var/tmp": "rw,noexec,nosuid,size=64m",
623
- },
588
+ CapDrop: isEphemeral ? undefined : ["ALL"],
589
+ CapAdd: isEphemeral ? undefined : ["NET_BIND_SERVICE"],
590
+ ReadonlyRootfs: !isEphemeral,
591
+ Tmpfs: isEphemeral
592
+ ? undefined
593
+ : {
594
+ "/tmp": "rw,noexec,nosuid,size=64m",
595
+ "/var/tmp": "rw,noexec,nosuid,size=64m",
596
+ },
624
597
  };
625
598
 
626
- // Set tenant network isolation if NetworkPolicy is configured
627
- if (this.networkPolicy) {
599
+ // Set network: explicit profile.network takes precedence, then NetworkPolicy
600
+ if (profile.network) {
601
+ hostConfig.NetworkMode = profile.network;
602
+ } else if (this.networkPolicy) {
628
603
  const networkMode = await this.networkPolicy.prepareForContainer(profile.tenantId);
629
604
  hostConfig.NetworkMode = networkMode;
630
605
  }