diff --git a/libs/wingsdk/src/shared/misc.ts b/libs/wingsdk/src/shared/misc.ts index 17dff4650e0..d87cfb5a332 100644 --- a/libs/wingsdk/src/shared/misc.ts +++ b/libs/wingsdk/src/shared/misc.ts @@ -1,7 +1,8 @@ -import { ExecFileOptions, execFile } from "child_process"; +import { ExecOptions, ExecFileOptions, exec, execFile } from "child_process"; import { readFileSync } from "fs"; import { promisify } from "util"; +const execPromise = promisify(exec); const execFilePromise = promisify(execFile); export function readJsonSync(file: string) { @@ -35,6 +36,19 @@ export async function runCommand( return stdout; } +/** + * Just a helpful wrapper around `exec` that returns a promise. + * This will run commands through the shell, while `runCommand` doesn't. + */ +export async function shell( + cmd: string, + args: string[], + options?: ExecOptions +): Promise { + const { stdout } = await execPromise(cmd + " " + args.join(" "), options); + return stdout; +} + export function isPath(s: string) { s = normalPath(s); return s.startsWith("./") || s.startsWith("/"); diff --git a/libs/wingsdk/src/target-sim/container.inflight.ts b/libs/wingsdk/src/target-sim/container.inflight.ts index c21e0fa4e16..b792a1c9d22 100644 --- a/libs/wingsdk/src/target-sim/container.inflight.ts +++ b/libs/wingsdk/src/target-sim/container.inflight.ts @@ -1,6 +1,6 @@ import { IContainerClient, HOST_PORT_ATTR } from "./container"; import { ContainerAttributes, ContainerSchema } from "./schema-resources"; -import { isPath, runCommand } from "../shared/misc"; +import { isPath, runCommand, shell } from "../shared/misc"; import { ISimulatorContext, ISimulatorResourceInstance, @@ -9,6 +9,8 @@ import { import { Duration, TraceType } from "../std"; import { Util } from "../util"; +export const WING_STATE_DIR_ENV = "WING_STATE_DIR"; + export class Container implements IContainerClient, ISimulatorResourceInstance { private readonly imageTag: string; private readonly containerName: string; @@ -89,7 +91,12 @@ export class Container implements IContainerClient, ISimulatorResourceInstance { this.log(`starting container from image ${this.imageTag}`); this.log(`docker ${dockerRun.join(" ")}`); - await runCommand("docker", dockerRun); + await shell("docker", dockerRun, { + env: { + ...process.env, + [WING_STATE_DIR_ENV]: this.context.statedir, + }, + }); this.log(`containerName=${this.containerName}`); diff --git a/libs/wingsdk/src/target-sim/container.md b/libs/wingsdk/src/target-sim/container.md index 6a150639ce5..7fe2ec02c99 100644 --- a/libs/wingsdk/src/target-sim/container.md +++ b/libs/wingsdk/src/target-sim/container.md @@ -44,6 +44,23 @@ new sim.Container( ); ``` +### Retaining state + +When the Wing Console is closed, all containers are stopped and removed. +To retain the state of a container across console restarts, you can mount a volume +to a subdirectory of the resource's simulator state directory, which is available through `$WING_STATE_DIR`: + +```js +new sim.Container( + name: "my-service", + image: "./my-service", + containerPort: 8080, + volumes: ["$WING_STATE_DIR/volume1:/var/data"], +); +``` + +`$WING_STATE_DIR` is a directory that is unique to that `sim.Container` instance. + ## API * `name` - a name for the container. diff --git a/libs/wingsdk/test/target-sim/container.test.ts b/libs/wingsdk/test/target-sim/container.test.ts index eae688372f9..65ec8a326d9 100644 --- a/libs/wingsdk/test/target-sim/container.test.ts +++ b/libs/wingsdk/test/target-sim/container.test.ts @@ -1,4 +1,4 @@ -import { cpSync, writeFileSync } from "fs"; +import { cpSync, writeFileSync, readdirSync } from "fs"; import { join, basename } from "path"; import { test, expect } from "vitest"; import { Function, IFunctionClient } from "../../src/cloud"; @@ -172,3 +172,42 @@ test("simple container with a volume", async () => { await sim.stop(); }); + +test("container can mount a volume to the state directory", async () => { + const app = new SimApp(); + + const c = new Container(app, "Container", { + name: "my-app", + image: join(__dirname, "my-docker-image.mounted-volume"), + containerPort: 3000, + volumes: ["$WING_STATE_DIR:/tmp"], + }); + + new Function( + app, + "Function", + Testing.makeHandler( + ` + async handle() { + const url = "http://localhost:" + this.hostPort; + const res = await fetch(url); + return res.text(); + } + `, + { hostPort: { obj: c.hostPort, ops: [] } } + ) + ); + + const sim = await app.startSimulator(); + sim.onTrace({ callback: (trace) => console.log(">", trace.data.message) }); + + const fn = sim.getResource("root/Function") as IFunctionClient; + const response = await fn.invoke(); + expect(response).contains("hello.txt"); + + const statedir = sim.getResourceStateDir("root/Container"); + const files = readdirSync(statedir); + expect(files).toEqual(["hello.txt"]); + + await sim.stop(); +}); diff --git a/libs/wingsdk/test/target-sim/my-docker-image.mounted-volume/Dockerfile b/libs/wingsdk/test/target-sim/my-docker-image.mounted-volume/Dockerfile new file mode 100644 index 00000000000..686c128323e --- /dev/null +++ b/libs/wingsdk/test/target-sim/my-docker-image.mounted-volume/Dockerfile @@ -0,0 +1,4 @@ +FROM node:20.8.0-alpine +EXPOSE 3000 +ADD index.js /app/index.js +ENTRYPOINT [ "/app/index.js" ] \ No newline at end of file diff --git a/libs/wingsdk/test/target-sim/my-docker-image.mounted-volume/index.js b/libs/wingsdk/test/target-sim/my-docker-image.mounted-volume/index.js new file mode 100755 index 00000000000..c4480a89e8b --- /dev/null +++ b/libs/wingsdk/test/target-sim/my-docker-image.mounted-volume/index.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node +const http = require("http"); +const fs = require("fs"); + +process.on("SIGINT", () => { + console.info("Interrupted"); + process.exit(0); +}); + +const server = http.createServer((req, res) => { + console.log(`request received: ${req.method} ${req.url}`); + res.end(fs.readdirSync("/tmp").join("\n")); +}); + +fs.writeFileSync("/tmp/hello.txt", "Hello, World!", "utf8"); + +console.log("listening on port 3000"); +server.listen(3000);