Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f8fcf9d
Squashed commit of the following:
mantrakp04 Mar 18, 2026
0010642
Refactor QEMU emulator setup and consolidate cloud-init configurations
mantrakp04 Mar 18, 2026
c2e4698
Refactor local emulator configuration management and remove obsolete …
mantrakp04 Mar 18, 2026
4508f24
Enhance Dockerfile for local emulator and server setup
mantrakp04 Mar 19, 2026
dec62a0
Refactor QEMU emulator build and snapshot handling
mantrakp04 Mar 19, 2026
580ff10
Refactor local emulator setup and consolidate services
mantrakp04 Mar 19, 2026
e3592bb
Add base image fingerprinting for QEMU snapshots
mantrakp04 Mar 19, 2026
5a729ec
Enhance configuration and permissions in local emulator setup
mantrakp04 Mar 19, 2026
c1a15ff
Enhance local emulator configuration and file management
mantrakp04 Mar 19, 2026
0496cd2
Add QEMU emulator build workflow and emulator command integration
mantrakp04 Mar 19, 2026
4a43a88
Update emulator configuration and enhance CLI functionality
mantrakp04 Mar 19, 2026
fe16a71
Merge branch 'dev' into emu-with-a-q
mantrakp04 Mar 19, 2026
5d98b44
Remove deprecated configuration files and streamline emulator CLI com…
mantrakp04 Mar 19, 2026
3321e5c
Update QEMU emulator scripts to improve KVM detection and service sta…
mantrakp04 Mar 19, 2026
8336d48
Refactor local emulator type handling and architecture detection
mantrakp04 Mar 19, 2026
298062a
Enhance local emulator configuration handling and file management
mantrakp04 Mar 19, 2026
578593a
Enhance QEMU emulator build workflow and local emulator configuration
mantrakp04 Mar 19, 2026
80ee1ab
Merge branch 'dev' into emu-with-a-q
mantrakp04 Mar 19, 2026
42bc125
Enhance setBranchConfigOverride for local emulator file handling
mantrakp04 Mar 19, 2026
4be8a82
Update QEMU emulator build workflow for improved performance and reli…
mantrakp04 Mar 19, 2026
f3514f8
Refactor QEMU emulator build and local emulator configuration
mantrakp04 Mar 20, 2026
d5d2a09
Increase EMULATOR_READY_TIMEOUT in QEMU emulator build workflow
mantrakp04 Mar 20, 2026
e509947
Enhance local emulator setup with environment generation and configur…
mantrakp04 Mar 20, 2026
ad2db19
Refactor local emulator configuration validation and enhance CLI tests
mantrakp04 Mar 20, 2026
a233e64
Update local emulator environment handling and documentation
mantrakp04 Mar 20, 2026
14f8164
Enhance local emulator project handling and configuration validation
mantrakp04 Mar 23, 2026
9d0f7c1
Refactor local emulator scripts and configuration management
mantrakp04 Mar 23, 2026
77d87fd
Refactor local emulator setup and enhance GitHub Actions workflow
mantrakp04 Mar 23, 2026
bef4aab
Enhance QEMU emulator build workflow and add documentation
mantrakp04 Mar 24, 2026
6d9d4e7
Merge branch 'dev' into emu-with-a-q
mantrakp04 Mar 24, 2026
771ed15
Update CLI tests for emulator commands
mantrakp04 Mar 24, 2026
0f3daeb
Merge branch 'dev' into emu-with-a-q
mantrakp04 Mar 27, 2026
950d74a
remove stack.config.ts
mantrakp04 Apr 1, 2026
26254c4
Merge branch 'dev' into emu-with-a-q
mantrakp04 Apr 2, 2026
7daea68
Increase QEMU smoke test timeout to 60 minutes
mantrakp04 Apr 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions .github/workflows/qemu-emulator-build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
name: Build & Publish QEMU Emulator Images
Comment thread
mantrakp04 marked this conversation as resolved.

on:
push:
branches:
- main
- dev
pull_request:
paths:
- 'docker/local-emulator/**'
- 'packages/stack-cli/src/commands/emulator.ts'
- '.github/workflows/qemu-emulator-build.yaml'
workflow_dispatch:
inputs:
Comment thread
mantrakp04 marked this conversation as resolved.
publish:
description: 'Publish images to GitHub Releases'
type: boolean
default: false

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}

env:
EMULATOR_IMAGE_NAME: stack-local-emulator

jobs:
build:
name: Build QEMU Image (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubicloud-standard-8
docker_platform: linux/amd64
- arch: arm64
runner: ubicloud-standard-8-arm
docker_platform: linux/arm64

steps:
- uses: actions/checkout@v6

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Install QEMU dependencies
run: |
sudo apt-get update
sudo apt-get install -y qemu-system-x86 qemu-system-arm qemu-utils genisoimage socat qemu-efi-aarch64

- name: Build Docker emulator image
run: |
docker buildx build \
--platform ${{ matrix.docker_platform }} \
--tag ${{ env.EMULATOR_IMAGE_NAME }} \
--load \
-f docker/local-emulator/Dockerfile \
.

- name: Build QEMU image
run: |
chmod +x docker/local-emulator/qemu/build-image.sh
docker/local-emulator/qemu/build-image.sh ${{ matrix.arch }}

- name: Start emulator and verify
run: |
chmod +x docker/local-emulator/qemu/run-emulator.sh
EMULATOR_ARCH=${{ matrix.arch }} \
EMULATOR_READY_TIMEOUT=300 \
docker/local-emulator/qemu/run-emulator.sh start

- name: Verify services are healthy
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh status

- name: Stop emulator
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh stop
Comment thread
mantrakp04 marked this conversation as resolved.

- name: Package image
run: |
BASE_IMG="docker/local-emulator/qemu/images/stack-emulator-${{ matrix.arch }}.qcow2"
cp "$BASE_IMG" "stack-emulator-${{ matrix.arch }}.qcow2"

- name: Upload image artifact
uses: actions/upload-artifact@v4
with:
name: qemu-emulator-${{ matrix.arch }}
path: stack-emulator-${{ matrix.arch }}.qcow2
retention-days: 30
compression-level: 0

publish:
name: Publish to GitHub Releases
needs: build
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.publish)
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- uses: actions/checkout@v6

- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts

- name: Prepare release assets
run: |
mkdir -p release
SHORT_SHA="${GITHUB_SHA:0:8}"
BRANCH="${GITHUB_REF_NAME}"
DATE="$(date -u +%Y%m%d)"
TAG="emulator-${BRANCH}-${DATE}-${SHORT_SHA}"
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "SHORT_SHA=${SHORT_SHA}" >> "$GITHUB_ENV"

for f in artifacts/qemu-emulator-*/*.qcow2; do
cp "$f" release/
done

ls -lh release/

- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ env.RELEASE_TAG }}" \
--title "QEMU Emulator — ${{ github.ref_name }} (${{ env.SHORT_SHA }})" \
--notes "$(cat <<'EOF'
## QEMU Emulator Images

Built from \`${{ github.ref_name }}\` @ \`${{ github.sha }}\`

### Images
| File | Description |
|------|-------------|
| `stack-emulator-arm64.qcow2` | ARM64 emulator image |
| `stack-emulator-amd64.qcow2` | AMD64 emulator image |

### Usage
```bash
stack emulator pull
stack emulator run
```
EOF
)" \
--prerelease \
release/*
Comment thread
mantrakp04 marked this conversation as resolved.
Outdated

- name: Update latest tag for branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
LATEST_TAG="emulator-${{ github.ref_name }}-latest"
# Delete existing latest release if it exists
gh release delete "$LATEST_TAG" --yes 2>/dev/null || true
git tag -d "$LATEST_TAG" 2>/dev/null || true
git push origin ":refs/tags/$LATEST_TAG" 2>/dev/null || true

gh release create "$LATEST_TAG" \
--title "QEMU Emulator — ${{ github.ref_name }} (latest)" \
--notes "Latest emulator images from \`${{ github.ref_name }}\`. Auto-updated on each build." \
--prerelease \
release/*
Comment thread
mantrakp04 marked this conversation as resolved.
Outdated
7 changes: 7 additions & 0 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,13 @@ export async function seed() {
} else {
console.log('Ensured emulator user is a member of emulator team');
}

await grantTeamPermission(internalPrisma, {
tenancy: internalTenancy,
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
userId: LOCAL_EMULATOR_ADMIN_USER_ID,
permissionId: "team_admin",
});
Comment thread
mantrakp04 marked this conversation as resolved.
}

console.log('Seeding complete!');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import fs from "fs/promises";
import * as path from "path";

type LocalEmulatorProjectMappingRow = {
Expand Down Expand Up @@ -193,30 +192,16 @@ export const POST = createSmartRouteHandler({

const absoluteFilePath = path.resolve(req.body.absolute_file_path);

// Validate file exists before creating a project
let fileExists: boolean;
try {
await fs.access(absoluteFilePath);
fileExists = true;
} catch {
fileExists = false;
}
if (!fileExists) {
throw new StatusError(StatusError.BadRequest, `Config file not found: ${absoluteFilePath}`);
}

// If the file is empty, write a default config
const fileContent = await fs.readFile(absoluteFilePath, "utf-8");
if (fileContent.trim() === "") {
await writeConfigToFile(absoluteFilePath, {});
}

await assertLocalEmulatorOwnerTeamReadiness();

const projectId = await getOrCreateLocalEmulatorProjectId(absoluteFilePath);
const credentials = await getOrCreateCredentials(projectId);
const fileConfig = await readConfigFromFile(absoluteFilePath);

if (Object.keys(fileConfig).length === 0) {
await writeConfigToFile(absoluteFilePath, {});
}

return {
statusCode: 200 as const,
bodyType: "json" as const,
Expand Down
16 changes: 8 additions & 8 deletions apps/backend/src/lib/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,14 +266,6 @@ export async function setBranchConfigOverride(options: {
}): Promise<void> {
const newConfig = migrateConfigOverride("branch", options.branchConfigOverride);

if (isLocalEmulatorEnabled() && await isLocalEmulatorProject(options.projectId)) {
const filePath = await getLocalEmulatorFilePath(options.projectId);
if (filePath) {
await writeConfigToFile(filePath, newConfig);
return;
}
}

// large configs make our DB slow; let's prevent them early
const newConfigString = JSON.stringify(newConfig);
if (newConfigString.length > 1_000_000) {
Expand Down Expand Up @@ -303,6 +295,14 @@ export async function setBranchConfigOverride(options: {
config: newConfig,
},
});

// In the local emulator, write config changes back to the config file
if (isLocalEmulatorEnabled()) {
const filePath = await getLocalEmulatorFilePath(options.projectId);
if (filePath) {
await writeConfigToFile(filePath, newConfig);
}
}
Comment thread
mantrakp04 marked this conversation as resolved.
Outdated
}

/**
Expand Down
33 changes: 33 additions & 0 deletions apps/backend/src/lib/local-emulator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readConfigFromFile } from "./local-emulator";

describe("local emulator config", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
Comment thread
mantrakp04 marked this conversation as resolved.

it("reads config from STACK_LOCAL_EMULATOR_CONFIG_CONTENT env var when set", async () => {
const content = `export const config = { auth: { allowLocalhost: true } };\n`;
vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64"));

await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toMatchInlineSnapshot(`
{
"auth": {
"allowLocalhost": true,
},
}
`);
});

it("returns empty object when env var is not set and file does not exist", async () => {
await expect(readConfigFromFile("/nonexistent/stack.config.ts")).resolves.toEqual({});
});

it("returns empty object when env var content is empty", async () => {
const content = ``;
vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64"));

await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toEqual({});
});
});
61 changes: 42 additions & 19 deletions apps/backend/src/lib/local-emulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,26 +36,40 @@ export async function isLocalEmulatorProject(projectId: string) {
return project !== null;
}

export async function getLocalEmulatorFilePath(projectId: string): Promise<string | null> {
const result = await globalPrismaClient.localEmulatorProject.findUnique({
where: { projectId },
select: { absoluteFilePath: true },
});
return result?.absoluteFilePath ?? null;
/**
* Resolves the file path for config files in the local emulator.
*
* In the QEMU emulator, the host filesystem is mounted at /host via virtio-9p.
* The DB stores absolute host paths (e.g. /Users/foo/project/stack.config.ts), so we
* try /host/<path> first, then fall back to the original path for non-QEMU environments
* (e.g. Docker Compose where the path is directly accessible).
*/
async function resolveConfigFilePath(filePath: string): Promise<string> {
const hostMountedPath = path.join("/host", filePath);
try {
await fs.access(hostMountedPath);
return hostMountedPath;
} catch {
return filePath;
}
Comment thread
mantrakp04 marked this conversation as resolved.
Outdated
}
Comment thread
mantrakp04 marked this conversation as resolved.
Outdated

export async function readConfigFromFile(filePath: string): Promise<Record<string, unknown>> {
let content: string;
try {
content = await fs.readFile(filePath, "utf-8");
} catch (e: any) {
if (e?.code === "ENOENT") {
throw new StatusError(StatusError.BadRequest, `Config file not found: ${filePath}`);
}
throw e;
const envContent = getEnvVariable("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", "");
const resolvedPath = envContent ? filePath : await resolveConfigFilePath(filePath);
const content = envContent
? Buffer.from(envContent, "base64").toString("utf-8")
: await fs.readFile(resolvedPath, "utf-8").catch((error: NodeJS.ErrnoException) => {
Comment thread
mantrakp04 marked this conversation as resolved.
Outdated
if (error.code === "ENOENT") return null;
throw error;
});

if (content === null || content.trim() === "") {
return {};
}

const jiti = createJiti(import.meta.url, { cache: false });
const mod = jiti.evalModule(content, { filename: filePath }) as Record<string, unknown>;
const mod = jiti.evalModule(content, { filename: resolvedPath }) as Record<string, unknown>;
const config = mod.config;
Comment thread
mantrakp04 marked this conversation as resolved.
Outdated
if (!isValidConfig(config)) {
throw new StatusError(StatusError.BadRequest, `Invalid config in ${filePath}. The file must export a 'config' object.`);
Expand All @@ -64,8 +78,17 @@ export async function readConfigFromFile(filePath: string): Promise<Record<strin
}

export async function writeConfigToFile(filePath: string, config: Record<string, unknown>): Promise<void> {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const content = `export const config = ${JSON.stringify(config, null, 2)};\n`;
await fs.writeFile(filePath, content, "utf-8");
const resolvedPath = await resolveConfigFilePath(filePath);
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
const configString = JSON.stringify(config, null, 2);
const content = `export const config = ${configString};\n`;
await fs.writeFile(resolvedPath, content, "utf-8");
}

export async function getLocalEmulatorFilePath(projectId: string): Promise<string | null> {
const project = await globalPrismaClient.localEmulatorProject.findUnique({
where: { projectId },
select: { absoluteFilePath: true },
});
return project?.absoluteFilePath ?? null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export default function PageClient() {
</DialogHeader>
<div className="space-y-3">
<Typography variant="secondary">
Enter the absolute path to your local Stack config file. The local emulator will create or reuse the mapped project and open it in the dashboard.
Enter the absolute path to your local Stack config file. If it does not exist yet, the local emulator will generate it with a default config, create or reuse the mapped project, and open it in the dashboard.
</Typography>
<Input
autoFocus
Expand All @@ -281,7 +281,7 @@ export default function PageClient() {
Cancel
</Button>
<Button onClick={handleOpenConfigFile} loading={openingConfigFile}>
Open project
Open or create project
</Button>
</DialogFooter>
</DialogContent>
Expand Down
Loading
Loading