fix build issue for assests missing

This commit is contained in:
Hydra
2026-07-22 22:15:37 +03:00
parent 4908bd16e8
commit 3381e5423b
44 changed files with 1374 additions and 156 deletions
+1 -1
View File
@@ -1 +1 @@
1.3.10
1.3.3
+9 -1
View File
@@ -210,6 +210,14 @@ jobs:
label: Linux x64
find: "-name *.AppImage"
asset: Openship.AppImage
# GitHub-hosted native aarch64 runner → stage.ts derives arm64 from
# process.arch, so the API bun --compile + the AppImage build are arm64.
# x64 keeps the legacy `Openship.AppImage` name (auto-update compat);
# arm64 is a NEW asset selected only by arm64 clients.
- os: ubuntu-24.04-arm
label: Linux arm64
find: "-name *.AppImage"
asset: Openship-arm64.AppImage
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
@@ -544,7 +552,7 @@ jobs:
TAG="${GITHUB_REF_NAME}"
# Upload whichever desktop installers built (fail-fast:false means a
# single-OS failure shouldn't block the release).
for asset in Openship-arm64.dmg Openship-x64.dmg Openship-win32-x64.zip Openship.AppImage; do
for asset in Openship-arm64.dmg Openship-x64.dmg Openship-win32-x64.zip Openship.AppImage Openship-arm64.AppImage; do
if [[ -f "dist/${asset}" ]]; then
gh release upload "${TAG}" "dist/${asset}" "dist/${asset}.sha256" --clobber
else
+49
View File
@@ -3,6 +3,55 @@
All notable changes to Openship. Versions follow [semver](https://semver.org);
the in-app updater surfaces critical advisories from `release-advisories.json`.
## 0.2.4
Native Apple Silicon builds, drop-in compatibility with other platforms' deploy
config, and a batch of self-hosting and reliability fixes.
### Downloads
- **Native Apple Silicon (arm64) desktop app** — macOS now ships separate
**arm64** and Intel **x64** dmgs (both built and SHA-256-checksummed in CI), so
Apple Silicon Macs run natively instead of under Rosetta. Windows (x64) and
Linux (AppImage) are unchanged.
### Deploy · stack detection
- **Deploys repos already configured for another platform, as-is** — the stack
detector now reads **`railway.toml`/`railway.json`** and **`vercel.json`**
(build / install / start / output commands, framework, and routing) and folds
them over its own detection. A repo that already tells Railway or Vercel how to
build it deploys the same way here, no reconfiguration. Every config source
runs through one shared parser registry (no per-source special-casing).
- **`openship.json`** — an optional repo-root config to declare build, routing,
env, and domains up front; it's authoritative over auto-detection and rides the
same engine, for the repo root and each monorepo sub-app.
### Self-hosting
- **Deploys to your own server by default** — a self-hosted instance targets the
server it runs on, never Openship Cloud, unless you explicitly choose cloud.
- **Health checks work when the control plane is containerized** — the
post-deploy probe reaches your app through the host gateway, so a containerized
self-host no longer fail-reverts an otherwise-healthy deploy.
- **OpenResty installs on newer distros** — the edge install no longer pins the
APT repo to a codename that doesn't exist yet (e.g. Ubuntu 26.04), and self-heals
a box already broken by the old pin.
### CLI
- **`openship stop` actually stops** — the service and its children are reaped by
process group and any ports it held are swept, so a restart can't strand the
old process on a new port.
### Reliability & fixes
- Malformed JSON request bodies now return **400**, not 500.
- **Cloud static-output path is confined** — the Pages output path resolves
through one shared, sandboxed resolver so a build can't escape its output dir.
- Mail DNS scan **detects duplicate DMARC records**.
- OAuth discovery metadata is served correctly **behind a public URL**.
- SSH exec streams **close cleanly on timeout** instead of leaking.
- Bumped the Laravel deploy **test fixture** off a vulnerable `laravel/framework`
(CRLF email advisory) — a fixture only, never a shipped dependency.
> Highlights, not exhaustive — trim/adjust before tagging.
## 0.2.2
Apps and Jobs grow up, a self-hosted server can now talk to GitHub on its own,
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
/**
* Halt-and-report contract for the non-interactive self-install edge step.
*
* When a foreign proxy already holds 80/443 and no takeover was pre-authorized,
* ensureSelfEdgeInfra must NOT install (never blind-kill someone's proxy) and must
* RESOLVE `{ ok:false, reason:"edge_conflict" }` — not throw, not fall through to a
* bare cert failure downstream. With a clear edge, it installs as normal.
*
* @repo/adapters is mocked so this runs with no real box; process.platform/getuid
* are stubbed to satisfy the Linux+root guard that gates the whole path.
*/
const h = vi.hoisted(() => ({
canProceedClean: false,
sites: [{}, {}] as unknown[],
ensureFeature: vi.fn(async () => {}),
probeEdge: vi.fn(),
scanImportableSites: vi.fn(),
}));
vi.mock("@repo/adapters", () => ({
createExecutor: () => ({}),
SystemManager: class {
ensureFeature = h.ensureFeature;
},
probeEdge: h.probeEdge,
scanImportableSites: h.scanImportableSites,
canImportProxy: (p: string | undefined) => p === "nginx",
runEdgeTakeover: vi.fn(),
}));
import { ensureSelfEdgeInfra } from "./self-edge";
const origPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const origGetuid = process.getuid;
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
// @ts-expect-error — stub root so the not-root guard passes
process.getuid = () => 0;
h.canProceedClean = false;
h.probeEdge.mockImplementation(async () => ({
classification: h.canProceedClean ? "free" : "known",
canProceedClean: h.canProceedClean,
occupants: h.canProceedClean ? [] : [{ port: 80, command: "nginx", proxy: "nginx" }],
}));
h.scanImportableSites.mockImplementation(async () => ({ sites: h.sites, warnings: [] }));
});
afterEach(() => {
if (origPlatform) Object.defineProperty(process, "platform", origPlatform);
process.getuid = origGetuid;
});
describe("ensureSelfEdgeInfra — halt + report", () => {
it("occupied edge, no consent → { ok:false, reason:'edge_conflict' } and does NOT install", async () => {
const res = await ensureSelfEdgeInfra();
expect(res.ok).toBe(false);
expect(res.reason).toBe("edge_conflict");
expect(res.siteCount).toBe(2);
expect(res.occupants).toContain("nginx");
expect(h.ensureFeature).not.toHaveBeenCalled(); // never touched the box
});
it("free edge → installs ssl (openresty + certbot) and returns ok", async () => {
h.canProceedClean = true;
const res = await ensureSelfEdgeInfra();
expect(res.ok).toBe(true);
expect(h.ensureFeature).toHaveBeenCalledWith("ssl", expect.any(Function));
});
it("occupied edge WITH pre-authorized takeover → skips the guard and installs", async () => {
const res = await ensureSelfEdgeInfra(undefined, { edgeTakeover: true });
expect(res.ok).toBe(true);
expect(h.probeEdge).not.toHaveBeenCalled(); // guard skipped when takeover is authorized
expect(h.ensureFeature).toHaveBeenCalledWith("ssl", expect.any(Function));
});
});
+32 -2
View File
@@ -21,6 +21,9 @@ export interface SelfEdgeInfraProgress {
export interface SelfEdgeInfraResult {
ok: boolean;
reason?: string;
/** When reason === "edge_conflict": what holds 80/443 and how many sites it serves. */
occupants?: string;
siteCount?: number;
}
export interface SelfEdgeOptions {
@@ -98,9 +101,36 @@ async function runEnsure(
return { ok: true };
}
// Halt + report: with no pre-authorized takeover, if a foreign proxy already
// holds 80/443, do NOT install (OpenResty couldn't bind, and we never blind-kill
// someone's proxy). Report what's there — and how many sites it serves — so the
// operator re-runs with migrate/take-over, instead of a bare downstream cert error.
if (!options?.edgeTakeover) {
const status = await probeEdge(executor);
if (!status.canProceedClean && status.occupants.length > 0) {
const owner = status.occupants.map((o) => o.command ?? `port ${o.port}`).join(", ");
let siteCount = 0;
try {
const proxy = status.occupants.find((o) => o.proxy)?.proxy;
if (proxy && canImportProxy(proxy)) {
siteCount = (await scanImportableSites(executor, proxy)).sites.length;
}
} catch {
/* best-effort site count only */
}
const sitesNote = siteCount > 0 ? ` serving ${siteCount} site${siteCount === 1 ? "" : "s"}` : "";
log(
`An existing proxy (${owner})${sitesNote} is using ports 80 and 443. Openship needs its own ` +
`load balancer (OpenResty) there for managed HTTPS — left it running. Re-run setup and choose ` +
`migrate or take-over to continue.`,
"warn",
);
return { ok: false, reason: "edge_conflict", occupants: owner, siteCount };
}
}
// Install OpenResty + certbot (idempotent). edgeTakeover authorizes reclaiming
// 80/443 from an existing proxy; without it an occupied edge throws instead of
// blind-killing.
// 80/443 from an existing proxy without prompting.
const installerConfig = options?.edgeTakeover
? { edgePolicy: { mode: "takeover" as const, stopTargets: [] } }
: undefined;
@@ -9,15 +9,22 @@
*/
import type { DockerContainerDetail } from "@repo/adapters";
import { repos } from "@repo/db";
import { createServerDockerRuntime } from "../../lib/deployment-runtime";
import { sshManager } from "../../lib/ssh-manager";
import { parseComposeFile, type ComposeService } from "../../lib/compose-parser";
import { reconcileStack, type DiscoveredStack } from "./docker-reconcile";
import { readManifest, type ManifestProjectEntry } from "../../lib/openship-manifest";
import {
reconcileStack,
reconcileOpenshipProjects,
type DiscoveredStack,
} from "./docker-reconcile";
export type {
DiscoveredStack,
DiscoveredService,
DiscoveredVolumeMount,
OpenshipProjectGroup,
} from "./docker-reconcile";
export { reconcileStack } from "./docker-reconcile";
@@ -108,18 +115,26 @@ export async function discoverServerStack(
rt.listAllNetworks(),
]);
// Exclude anything Openship already manages — deploy containers carry
// `openship.project`, but match the whole `openship.*` namespace so infra/
// build helpers never show up as "adoptable".
// Split by ownership. GENERIC candidates (no openship.* label) feed the
// normal adopt grid. OPENSHIP-owned deploy containers are recovered as their
// own projects (re-import) — build helpers (`openship.build`) are neither.
const isOpenshipOwned = (labels: Record<string, string>) =>
Object.keys(labels).some((k) => k === "openship" || k.startsWith("openship."));
const managed = containers.filter((c) => isOpenshipOwned(c.labels));
const candidates = containers.filter((c) => !isOpenshipOwned(c.labels));
const managedApp = managed.filter(
(c) => c.labels["openship.project"] && !c.labels["openship.build"],
);
step(`Inspecting ${candidates.length} container(s)…`);
const details = (
await mapLimit(candidates, 5, (c) => rt.inspectContainer(c.id))
).filter((d): d is DockerContainerDetail => d !== null);
const [details, managedDetails] = await Promise.all([
mapLimit(candidates, 5, (c) => rt.inspectContainer(c.id)).then((d) =>
d.filter((x): x is DockerContainerDetail => x !== null),
),
mapLimit(managedApp, 5, (c) => rt.inspectContainer(c.id)).then((d) =>
d.filter((x): x is DockerContainerDetail => x !== null),
),
]);
// Group by compose project (standalone containers key on "") for the
// compose-file reads; reconciliation itself is pure (see reconcileStack).
@@ -134,9 +149,12 @@ export async function discoverServerStack(
step("Reading compose files…");
const declared = await readComposeDeclarations(serverId, groups);
// Fetch each distinct image's baked-in env once, so discovery can subtract
// image defaults and import only the vars the operator actually set.
const uniqueImages = [...new Set(details.map((d) => d.image).filter(Boolean))];
// Fetch each distinct image's baked-in env once (candidates AND openship
// containers), so discovery can subtract image defaults and import only the
// vars the operator actually set.
const uniqueImages = [
...new Set([...details, ...managedDetails].map((d) => d.image).filter(Boolean)),
];
const imageInfoPairs = await mapLimit(uniqueImages, 4, async (ref) => {
const [env, cmd] = await Promise.all([rt.inspectImageEnv(ref), rt.inspectImageCmd(ref)]);
return [ref, { env: new Set(env), cmd }] as const;
@@ -144,15 +162,47 @@ export async function discoverServerStack(
const imageDefaults = new Map(imageInfoPairs.map(([ref, v]) => [ref, v.env]));
const imageCmds = new Map(imageInfoPairs.map(([ref, v]) => [ref, v.cmd]));
// Recover Openship projects: read the on-server manifest (rich, faithful
// recipe) and cross-reference each openship.project id against THIS org's DB.
// Present here = genuinely managed → counted; absent = orphaned → re-importable.
let openshipProjects: DiscoveredStack["openshipProjects"] = [];
let alreadyManaged = 0;
const projectIds = [...new Set(managedApp.map((c) => c.labels["openship.project"]!).filter(Boolean))];
if (projectIds.length > 0) {
step("Recovering Openship projects…");
const manifest = await sshManager
.withExecutor(serverId, (exec) => readManifest(exec))
.catch(() => null);
const manifestById = manifest
? new Map<string, ManifestProjectEntry>(manifest.projects.map((p) => [p.id, p]))
: null;
const knownHereIds = new Set<string>();
await Promise.all(
projectIds.map(async (id) => {
const row = await repos.project.findByIdInOrganization(id, organizationId);
if (row) knownHereIds.add(id);
}),
);
openshipProjects = reconcileOpenshipProjects({
managedDetails,
manifestById,
knownHereIds,
imageDefaults,
imageCmds,
});
alreadyManaged = managedApp.filter((c) => knownHereIds.has(c.labels["openship.project"]!)).length;
}
return reconcileStack({
serverId,
details,
volumes,
networks,
declared,
alreadyManaged: managed.length,
alreadyManaged,
imageDefaults,
imageCmds,
openshipProjects,
});
} finally {
await rt.dispose();
@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import type { DockerContainerDetail } from "@repo/adapters";
import type { ManifestProjectEntry } from "../../lib/openship-manifest";
import { reconcileOpenshipProjects } from "./docker-reconcile";
function container(over: Partial<DockerContainerDetail> & { labels: Record<string, string> }): DockerContainerDetail {
return {
id: over.id ?? "c1",
name: over.name ?? "svc",
image: over.image ?? "postgres:17",
imageId: "sha256:abc",
state: over.state ?? "running",
env: over.env ?? [],
networks: over.networks ?? [],
mounts: over.mounts ?? [],
ports: over.ports ?? [],
...over,
};
}
function manifestEntry(over: Partial<ManifestProjectEntry> & { id: string }): ManifestProjectEntry {
return {
slug: "slug",
name: "Name",
organizationId: "org_1",
groupId: "app_1",
domains: [],
updatedAt: "2026-01-01T00:00:00Z",
...over,
};
}
describe("reconcileOpenshipProjects", () => {
it("recovers an orphaned project and enriches name/slug/domains from the manifest", () => {
const details = [
container({
id: "c1",
name: "web",
image: "myapp:latest",
labels: { "openship.project": "proj_abc", "openship.service": "web", "openship.deployment": "dep_1" },
}),
container({
id: "c2",
name: "db",
labels: { "openship.project": "proj_abc", "openship.service": "db" },
}),
];
const manifestById = new Map<string, ManifestProjectEntry>([
["proj_abc", manifestEntry({ id: "proj_abc", name: "Shop", slug: "shop", domains: ["shop.example.com"] })],
]);
const out = reconcileOpenshipProjects({ managedDetails: details, manifestById, knownHereIds: new Set() });
expect(out).toHaveLength(1);
const p = out[0]!;
expect(p).toMatchObject({ projectId: "proj_abc", knownHere: false, suggestedName: "Shop", slug: "shop" });
expect(p.domains).toEqual(["shop.example.com"]);
expect(p.deploymentId).toBe("dep_1");
expect(p.services.map((s) => s.name).sort()).toEqual(["db", "web"]);
});
it("flags a project already present in this DB as knownHere", () => {
const details = [
container({ labels: { "openship.project": "proj_known", "openship.service": "web" } }),
];
const out = reconcileOpenshipProjects({
managedDetails: details,
manifestById: null,
knownHereIds: new Set(["proj_known"]),
});
expect(out[0]!.knownHere).toBe(true);
});
it("excludes build-helper containers (openship.build) from services", () => {
const details = [
container({ id: "c1", name: "web", labels: { "openship.project": "proj_x", "openship.service": "web" } }),
container({ id: "c2", name: "build", labels: { "openship.project": "proj_x", "openship.build": "sess_1" } }),
];
const out = reconcileOpenshipProjects({ managedDetails: details, manifestById: null, knownHereIds: new Set() });
expect(out).toHaveLength(1);
expect(out[0]!.services).toHaveLength(1);
expect(out[0]!.services[0]!.name).toBe("web");
});
it("falls back to a derived name when no manifest entry exists", () => {
const details = [
container({ name: "api", labels: { "openship.project": "proj_deadbeef00", "openship.service": "api" } }),
];
const out = reconcileOpenshipProjects({ managedDetails: details, manifestById: null, knownHereIds: new Set() });
expect(out[0]!.suggestedName).toBe("openship-deadbeef");
expect(out[0]!.slug).toBeUndefined();
});
it("recovers a single-app container that carries no openship.service label", () => {
const details = [
container({ id: "c1", name: "web-1", labels: { "openship.project": "proj_single", "openship.deployment": "dep_9" } }),
];
const out = reconcileOpenshipProjects({ managedDetails: details, manifestById: null, knownHereIds: new Set() });
expect(out[0]!.services).toHaveLength(1);
// No service label → the service name falls back to the container name.
expect(out[0]!.services[0]!.name).toBe("web-1");
});
it("ignores containers with no openship.project label", () => {
const details = [container({ labels: { "openship.network": "shop" } })];
const out = reconcileOpenshipProjects({ managedDetails: details, manifestById: null, knownHereIds: new Set() });
expect(out).toEqual([]);
});
});
@@ -22,6 +22,7 @@ import type {
import { classifyProxy } from "@repo/adapters";
import type { ComposeHealthcheck } from "@repo/core";
import type { ComposeService } from "../../lib/compose-parser";
import type { ManifestProjectEntry } from "../../lib/openship-manifest";
export interface DiscoveredVolumeMount {
/** "volume" reuses a named volume in place; "bind" is a host path. */
@@ -72,6 +73,39 @@ export interface DiscoveredGroup {
services: DiscoveredService[];
}
/**
* An OPENSHIP-owned project recovered from a server's live containers (matched by
* the `openship.project` label) + its `.openship/manifest.json` entry. `knownHere`
* = this project id already exists in the scanning instance's DB (genuinely
* managed here → not re-importable, just counted). `knownHere: false` = orphaned:
* the DB was reset (DR) or the server came from another Openship instance →
* re-importable, preserving the original id/slug so the live containers re-attach.
*/
export interface OpenshipProjectGroup {
/** Original Openship project id from the `openship.project` label. */
projectId: string;
/** Best-effort display name (manifest name/slug → compose project → derived). */
suggestedName: string;
/** Original slug (from the manifest) — preserved on re-import to keep URLs. */
slug?: string;
/** Domains from the manifest — restored as route state on re-import. */
domains?: string[];
/** Git source recovered from the manifest (restored on re-import). */
source?: {
gitProvider?: string | null;
gitOwner?: string | null;
gitRepo?: string | null;
gitBranch?: string | null;
};
runtimeMode?: string | null;
/** Whether this project id already exists in this instance's DB. */
knownHere: boolean;
/** Deployment id from the label/manifest — carried for future live-status recovery. */
deploymentId?: string;
/** Live service containers reconstructed from runtime state. */
services: DiscoveredService[];
}
export interface DiscoveredStack {
serverId: string;
/** compose "project" groupings found (com.docker.compose.project). */
@@ -85,8 +119,11 @@ export interface DiscoveredStack {
/** Stack-level notes for things Openship can't carry over 1:1. */
warnings: string[];
adoptable: boolean;
/** Containers skipped because Openship already manages them. */
/** Live containers already managed by a project in THIS instance's DB (count). */
alreadyManaged: number;
/** Openship projects recovered from the server (see {@link OpenshipProjectGroup});
* `knownHere: false` entries are re-importable. Empty when none found. */
openshipProjects: OpenshipProjectGroup[];
}
// Docker-injected / shell env that should never be imported as app config.
@@ -292,6 +329,8 @@ export function reconcileStack(opts: {
/** image ref → its baked-in default CMD tokens, dropped when the container
* only restates it (see toDiscoveredService). */
imageCmds?: Map<string, string[]>;
/** Openship projects recovered from the server (computed in the IO shell). */
openshipProjects?: OpenshipProjectGroup[];
}): DiscoveredStack {
const { serverId, details, volumes, networks, declared, alreadyManaged, imageDefaults, imageCmds } = opts;
@@ -368,5 +407,76 @@ export function reconcileStack(opts: {
warnings,
adoptable: services.length > 0,
alreadyManaged,
openshipProjects: opts.openshipProjects ?? [],
};
}
/**
* Reconstruct OPENSHIP-owned projects from their live containers + the server's
* `.openship/manifest.json`. Pure — the DB cross-reference (which ids are
* `knownHere`) and the manifest read happen in the IO shell and are passed in.
*
* Containers are grouped by their `openship.project` label. Build-helper
* containers (`openship.build`, no live app) are skipped. A single-app deploy
* container carries only `openship.project`/`openship.deployment` (no
* `openship.service`), so we DON'T require a service label — we recover the
* service name from `openship.service` when present, else the container name.
*/
export function reconcileOpenshipProjects(opts: {
managedDetails: DockerContainerDetail[];
/** Manifest entries keyed by project id (null when the server has no manifest). */
manifestById: Map<string, ManifestProjectEntry> | null;
/** Project ids that already exist in this instance's DB. */
knownHereIds: Set<string>;
imageDefaults?: Map<string, Set<string>>;
imageCmds?: Map<string, string[]>;
}): OpenshipProjectGroup[] {
const { managedDetails, manifestById, knownHereIds, imageDefaults, imageCmds } = opts;
const byProject = new Map<string, DockerContainerDetail[]>();
for (const d of managedDetails) {
const projectId = d.labels["openship.project"];
if (!projectId) continue; // not project-owned (infra/network helper) — skip
if (d.labels["openship.build"]) continue; // transient build container — not a service
const list = byProject.get(projectId) ?? [];
list.push(d);
byProject.set(projectId, list);
}
const out: OpenshipProjectGroup[] = [];
for (const [projectId, details] of byProject) {
const entry = manifestById?.get(projectId);
const services = details.map((d) => {
const svc = toDiscoveredService(d, undefined, imageDefaults?.get(d.image ?? ""), imageCmds?.get(d.image ?? ""));
const serviceLabel = d.labels["openship.service"];
return serviceLabel ? { ...svc, name: serviceLabel } : svc;
});
const deploymentId =
details.find((d) => d.labels["openship.deployment"])?.labels["openship.deployment"] ??
entry?.deployment?.id;
out.push({
projectId,
knownHere: knownHereIds.has(projectId),
suggestedName:
entry?.name ||
entry?.slug ||
details.find((d) => d.composeProject)?.composeProject ||
`openship-${projectId.replace(/^proj_/, "").slice(0, 8)}`,
slug: entry?.slug,
domains: entry?.domains,
source: entry
? {
gitProvider: entry.gitProvider,
gitOwner: entry.gitOwner,
gitRepo: entry.gitRepo,
gitBranch: entry.gitBranch,
}
: undefined,
runtimeMode: entry?.runtimeMode ?? undefined,
deploymentId,
services,
});
}
return out;
}
+158 -60
View File
@@ -15,9 +15,15 @@
*/
import { repos } from "@repo/db";
import { ensureProject } from "../projects/project-crud.service";
import { slugify } from "@repo/core";
import { ensureProject, createServicesProjectWithId } from "../projects/project-crud.service";
import { discoverServerStack } from "./docker-inspect.service";
import { EDGE_PORTS, parseComposePort, type DiscoveredVolumeMount } from "./docker-reconcile";
import {
EDGE_PORTS,
parseComposePort,
type DiscoveredService,
type DiscoveredVolumeMount,
} from "./docker-reconcile";
type EnsureBody = Parameters<typeof ensureProject>[0];
type ParsedComposeList = Parameters<typeof repos.service.syncFromCompose>[1];
@@ -29,6 +35,14 @@ export interface AdoptResult {
adopted: string[];
}
export interface ReimportResult {
projectId: string;
slug: string;
reimported: string[];
/** True: records + preserved id only; the user redeploys to finalize live state. */
deferredDeployment: true;
}
/** A discovered mount → compose volume string. Anonymous (no source) is dropped
* (its data isn't reusable in place). Named volumes keep their original bare
* name; bind mounts keep their host path. */
@@ -72,6 +86,59 @@ function normalizeHostPorts(
return { ports: out, droppedDuplicates };
}
/**
* Map selected discovered services → compose service rows for `syncFromCompose`.
* Shared by adopt AND re-import so the two paths can't drift: unique names,
* group-wide host-port de-dup, adopt-the-running-image (never rebuild), and —
* critically — services are left UNEXPOSED. Exposing here would fire the
* routing/OpenResty ensure mid-import (which needs the 80/443 takeover-consent
* modal the wizard can't surface); instead the user adds routes from the
* project's Domains tab, and THAT redeploy runs the one unified ensure-OpenResty
* + takeover-consent flow. Pushes a per-service warning when a host port is
* dropped as a duplicate.
*/
function buildAdoptedServiceRows(chosen: DiscoveredService[], selected: Set<string>): ParsedComposeList {
const nameCounts = new Map<string, number>();
const firstUnique = new Map<string, string>();
const uniqueNames = chosen.map((s) => {
const n = (nameCounts.get(s.name) ?? 0) + 1;
nameCounts.set(s.name, n);
const unique = n === 1 ? s.name : `${s.name}-${n}`;
if (!firstUnique.has(s.name)) firstUnique.set(s.name, unique);
return unique;
});
const claimedHostPorts = new Set<number>();
return chosen.map((s, i) => {
const { ports, droppedDuplicates } = normalizeHostPorts(s.ports, claimedHostPorts);
if (droppedDuplicates.length > 0) {
s.warnings.push(
`Host port(s) ${droppedDuplicates.join(", ")} already published by another service — ` +
`kept ${uniqueNames[i]} on the internal network only (reachable as ${uniqueNames[i]}:<port>).`,
);
}
return {
name: uniqueNames[i],
kind: "compose" as const,
// Adopt the running container AS-IS via its current image — we don't have
// its original build source, so never carry a build context (which would
// make the deploy rebuild-from-source and fail preflight). Only an
// image-less container (rare) falls back to its build context.
image: s.image,
build: s.image ? undefined : s.build,
dockerfile: s.image ? undefined : s.dockerfile,
ports,
// Only keep dependencies on services we're also adopting.
dependsOn: s.dependsOn.filter((d) => selected.has(d)).map((d) => firstUnique.get(d) ?? d),
environment: s.env,
volumes: s.volumes.map(volumeToComposeString).filter((v): v is string => v !== null),
command: s.command,
restart: s.restart,
advanced: s.healthcheck ? { healthcheck: s.healthcheck } : undefined,
};
});
}
export async function adoptServerStack(opts: {
serverId: string;
@@ -122,64 +189,7 @@ export async function adoptServerStack(opts: {
};
const { project_id, created } = await ensureProject(ensureBody, organizationId);
// Service names must be unique within a project, but two adopted containers
// can share a name (a standalone `postgres` + a compose `postgres`). Uniquify
// with a numeric suffix so neither silently overwrites the other on sync, and
// remap dependsOn to the first service that carried each original name.
const nameCounts = new Map<string, number>();
const firstUnique = new Map<string, string>();
const uniqueNames = chosen.map((s) => {
const n = (nameCounts.get(s.name) ?? 0) + 1;
nameCounts.set(s.name, n);
const unique = n === 1 ? s.name : `${s.name}-${n}`;
if (!firstUnique.has(s.name)) firstUnique.set(s.name, unique);
return unique;
});
// Host ports must be unique across the whole group — process services in
// order, first-claim-wins, so two services publishing the same host port
// (e.g. two postgres on 5432) don't collide at deploy time.
const claimedHostPorts = new Set<number>();
const parsed: ParsedComposeList = chosen.map((s, i) => {
const { ports, droppedDuplicates } = normalizeHostPorts(s.ports, claimedHostPorts);
if (droppedDuplicates.length > 0) {
s.warnings.push(
`Host port(s) ${droppedDuplicates.join(", ")} already published by another service — ` +
`kept ${uniqueNames[i]} on the internal network only (reachable as ${uniqueNames[i]}:<port>).`,
);
}
return {
name: uniqueNames[i],
kind: "compose" as const,
// Adoption takes the running container AS-IS via its current image — we
// don't have its original build source, so we never carry a build context
// (which would make the deploy try to rebuild-from-source and fail preflight
// with "repository URL or local path"). Only an image-less container (rare)
// falls back to its build context.
image: s.image,
build: s.image ? undefined : s.build,
dockerfile: s.image ? undefined : s.dockerfile,
ports,
// Left UNEXPOSED on purpose: an exposed free service would synthesize a
// subdomain route (usesManagedRouting is true for a self-hosted server
// deploy), which fires the routing/OpenResty ensure DURING migration — and
// self-hosted free domains need the cloud edge, so it'd fail preflight (or,
// with a foreign proxy present, raise the takeover prompt the wizard can't
// surface → timeout). The user routes each service from the project's
// Domains tab (Add route → exposes it) afterwards; that redeploy ensures
// OpenResty and reclaims 80/443 via the consent modal.
// Only keep dependencies on services we're also adopting.
dependsOn: s.dependsOn.filter((d) => selected.has(d)).map((d) => firstUnique.get(d) ?? d),
environment: s.env,
volumes: s.volumes
.map(volumeToComposeString)
.filter((v): v is string => v !== null),
command: s.command,
restart: s.restart,
advanced: s.healthcheck ? { healthcheck: s.healthcheck } : undefined,
};
});
const parsed = buildAdoptedServiceRows(chosen, selected);
const createdServices = await repos.service.syncFromCompose(project_id, parsed);
// Volume ownership: reuse the original bare-named volumes in place
@@ -202,3 +212,91 @@ export async function adoptServerStack(opts: {
adopted: chosen.map((s) => s.name),
};
}
/** Openship id shape — validated before we trust a server-supplied label as a PK. */
const PROJECT_ID_RE = /^proj_[A-Za-z0-9]+$/;
/**
* Re-import an ORPHANED Openship project recovered from a server (see
* `reconcileOpenshipProjects`): the DB was reset (DR) or the server came from
* another Openship instance. Rebuilds the project + compose service rows,
* PRESERVING the original id (+ slug) so the still-running containers' labels
* re-attach immediately — teardown/reclaim/network reconcile recognize them, and
* a later redeploy replaces same-id containers cleanly. Records only: no data
* move, no redeploy; the user redeploys from the project to finalize live state.
*
* Uses the SAME service mapping as adopt (`buildAdoptedServiceRows`) — services
* land UNEXPOSED, so routing/OpenResty is untouched here; adding a domain later
* runs the unified ensure-OpenResty + 80/443 takeover-consent flow.
*/
export async function reimportOpenshipProject(opts: {
serverId: string;
organizationId: string;
projectId: string;
projectName?: string;
serviceNames?: string[];
}): Promise<ReimportResult> {
const { serverId, organizationId, projectId, projectName, serviceNames } = opts;
// Never trust a raw label as a primary key without shape-checking it.
if (!PROJECT_ID_RE.test(projectId)) {
throw new Error("Invalid Openship project id.");
}
// Refuse-not-merge: if ANY project (any org, incl. soft-deleted) already owns
// this id, do not graft server-supplied state onto it.
const existing = await repos.project.findById(projectId);
if (existing) {
throw new Error("A project with this id already exists here — nothing to re-import.");
}
const stack = await discoverServerStack(serverId, organizationId);
const group = stack.openshipProjects.find((p) => p.projectId === projectId);
if (!group) {
throw new Error("That Openship project was not found on the server.");
}
if (group.knownHere) {
throw new Error("That Openship project is already managed by this instance.");
}
const selected = serviceNames?.length
? new Set(serviceNames)
: new Set(group.services.map((s) => s.name));
const chosen = group.services.filter((s) => selected.has(s.name) && !s.proxyKind);
if (chosen.length === 0) {
throw new Error("None of the selected services were found on the server.");
}
const name = projectName?.trim() || group.suggestedName;
const anyBuild = chosen.some((s) => !s.image && Boolean(s.build));
const created = await createServicesProjectWithId({
id: projectId,
name,
slug: group.slug || slugify(name),
organizationId,
hasBuild: anyBuild,
runtimeMode: group.runtimeMode === "bare" ? "bare" : "docker",
gitProvider: group.source?.gitProvider ?? undefined,
gitOwner: group.source?.gitOwner ?? undefined,
gitRepo: group.source?.gitRepo ?? undefined,
gitBranch: group.source?.gitBranch ?? undefined,
});
const parsed = buildAdoptedServiceRows(chosen, selected);
const createdServices = await repos.service.syncFromCompose(created.id, parsed);
// Reuse the original bare-named volumes in place (data survives) — combined
// with the preserved id, the running containers count as this project's own in
// the deploy volume-owner guard, so a redeploy reattaches without conflict.
for (const svc of createdServices) {
if (svc.namespaceVolumes !== false) {
await repos.service.update(svc.id, { namespaceVolumes: false });
}
}
return {
projectId: created.id,
slug: created.slug,
reimported: chosen.map((s) => s.name),
deferredDeployment: true,
};
}
@@ -16,7 +16,7 @@ import { isServerInOrg, param } from "../../lib/controller-helpers";
import { streamRunSSE } from "../../lib/run-sse";
import { streamSSE } from "../../lib/sse";
import { discoverServerStack } from "./docker-inspect.service";
import { adoptServerStack } from "./migrate.service";
import { adoptServerStack, reimportOpenshipProject } from "./migrate.service";
import { buildMigrationPreview } from "./migration-preflight";
import { migrationOrchestrator } from "./migration.orchestrator";
import { migrationRunBus } from "./migration.sse";
@@ -167,6 +167,49 @@ export async function adoptServer(c: Context) {
}
}
/**
* POST /migration/reimport { serverId, projectId, projectName?, serviceNames? }
*
* Recover an ORPHANED Openship project (DR / cross-instance) from a server,
* preserving its original id so the running containers re-attach. Records only —
* the user redeploys from the project to finalize. `organizationId` comes from
* the request context, never from server-supplied data.
*/
export async function reimportServer(c: Context) {
const body = await c.req.json<{
serverId?: string;
projectId?: string;
projectName?: string;
serviceNames?: string[];
}>();
const { serverId, projectId, projectName, serviceNames } = body;
if (!serverId) return c.json({ error: "serverId is required" }, 400);
if (!projectId?.trim()) return c.json({ error: "projectId is required" }, 400);
const ctx = getRequestContext(c);
await permission.assert(ctx, {
resourceType: "server",
resourceId: serverId,
action: "write",
});
if (!(await isServerInOrg(ctx, serverId))) {
return c.json({ error: "Server not found" }, 404);
}
try {
const result = await reimportOpenshipProject({
serverId,
organizationId: ctx.organizationId,
projectId: projectId.trim(),
projectName: projectName?.trim() || undefined,
serviceNames: Array.isArray(serviceNames) ? serviceNames : undefined,
});
return c.json({ success: true, ...result });
} catch (err) {
return c.json({ error: `Re-import failed: ${safeErrorMessage(err)}` }, 502);
}
}
/**
* POST /migration/preview { sourceServerId, targetServerId, serviceNames[] }
*
@@ -24,6 +24,8 @@ r.post("/scan", { tag: "server:write", collection: true }, migration.scanServer)
r.get("/scan/stream", { tag: "server:write", collection: true }, migration.scanServerStream);
// Create an Openship project from the selected discovered services (records only).
r.post("/adopt", { tag: "server:write", collection: true }, migration.adoptServer);
// Re-import an orphaned Openship project (DR / cross-instance), preserving its id.
r.post("/reimport", { tag: "server:write", collection: true }, migration.reimportServer);
// Read-only preview of a full migration (registry/build, volumes, warnings).
r.post("/preview", { tag: "server:write", collection: true }, migration.previewMigration);
@@ -357,6 +357,77 @@ async function createProductionProject(
}
}
/**
* Create a `services` project while PRESERVING an explicit project id — the
* re-import path (recovering an Openship project from a server's manifest). The
* preserved id means the server's still-running containers (labelled
* `openship.project=<id>`) re-attach immediately: teardown/reclaim/network
* reconcile recognize them, and a later redeploy replaces same-id containers
* cleanly. The slug is preserved when free, else uniquified (so the free
* subdomain regenerates to the original). Enforces the quota and creates a
* fresh project group; rolls the group back if the project insert fails.
*
* This deliberately does NOT go through `ensureProject` (name-based dedupe +
* generated id) — re-import needs the exact id and a create-only path.
*/
export async function createServicesProjectWithId(opts: {
id: string;
name: string;
slug: string;
organizationId: string;
hasBuild?: boolean;
runtimeMode?: "bare" | "docker";
gitProvider?: string | null;
gitOwner?: string | null;
gitRepo?: string | null;
gitBranch?: string | null;
autoDeploy?: boolean;
}): Promise<Project> {
await assertProjectQuota(opts.organizationId);
const slug = await uniqueProjectSlug(opts.organizationId, opts.slug);
const group = await repos.projectGroup.create({
organizationId: opts.organizationId,
name: opts.name,
slug,
gitProvider: opts.gitProvider ?? undefined,
gitOwner: opts.gitOwner ?? undefined,
gitRepo: opts.gitRepo ?? undefined,
gitUrl: projectGitUrl(opts.gitOwner, opts.gitRepo),
});
try {
const routing = deriveNextProjectRouteState({ slug }, { slug });
const created = await repos.project.create({
id: opts.id,
organizationId: opts.organizationId,
groupId: group.id,
name: opts.name,
slug,
environmentName: "Production",
environmentSlug: "production",
environmentType: "production",
gitProvider: opts.gitProvider ?? "github",
gitOwner: opts.gitOwner ?? undefined,
gitRepo: opts.gitRepo ?? undefined,
gitBranch: opts.gitBranch ?? "main",
gitUrl: projectGitUrl(opts.gitOwner, opts.gitRepo),
autoDeploy: !!opts.autoDeploy,
framework: "unknown", // services project — the stack lives on each service row
packageManager: "npm",
hasServer: true,
hasBuild: opts.hasBuild ?? false,
// services ⇒ docker runtime (same rule buildProductionProjectInput applies).
runtimeMode: opts.runtimeMode === "bare" ? "bare" : "docker",
});
await persistProjectRouteState(created.id, routing.publicEndpoints);
return created;
} catch (err) {
await repos.projectGroup.softDelete(group.id).catch(() => {});
throw err;
}
}
async function uniqueProjectSlug(organizationId: string, baseSlug: string) {
let slug = baseSlug;
let suffix = 2;
+3 -1
View File
@@ -36,7 +36,9 @@ function assetForPlatform(): { name: string; kind: AssetKind } {
return { name: arch === "arm64" ? "Openship-arm64.dmg" : "Openship-x64.dmg", kind: "dmg" };
}
if (platform === "win32") return { name: "Openship-win32-x64.zip", kind: "zip" };
if (platform === "linux") return { name: "Openship.AppImage", kind: "appimage" };
if (platform === "linux") {
return { name: arch === "arm64" ? "Openship-arm64.AppImage" : "Openship.AppImage", kind: "appimage" };
}
throw new Error(`Unsupported platform: ${platform} (${arch})`);
}
+48 -9
View File
@@ -267,14 +267,21 @@ async function promptLocalAdmin(): Promise<{ name: string; email: string; passwo
}
/** Consume the self-register SSE stream, driving the spinner until done. */
async function streamProvision(port: string, sessionId: string, s: ReturnType<typeof spinner>): Promise<boolean> {
async function streamProvision(
port: string,
sessionId: string,
s: ReturnType<typeof spinner>,
): Promise<{ ok: boolean; detail?: string }> {
let ok = false;
// Remember the last warn/error line so a failure (e.g. an existing proxy still
// on 80/443, or a cert issue) reports WHY instead of a generic "not ready".
let detail: string | undefined;
try {
const res = await fetch(`http://127.0.0.1:${port}/api/system/self-register/stream?id=${sessionId}`, {
headers: { "X-Internal-Token": ensureInternalToken() },
signal: AbortSignal.timeout(300_000),
});
if (!res.ok || !res.body) return false;
if (!res.ok || !res.body) return { ok: false };
const reader = (res.body as ReadableStream<Uint8Array>).getReader();
const decoder = new TextDecoder();
let buffer = "";
@@ -292,25 +299,31 @@ async function streamProvision(port: string, sessionId: string, s: ReturnType<ty
if (event === "log" && dataRaw) {
try {
const d = JSON.parse(dataRaw);
if (d.message) s.message(String(d.message).replace(/\s+/g, " ").slice(0, 68));
if (d.message) {
const msg = String(d.message).replace(/\s+/g, " ");
s.message(msg.slice(0, 68));
if (d.level === "warn" || d.level === "error") detail = msg;
}
} catch {
/* ignore */
}
} else if (event === "complete" && dataRaw) {
try {
ok = JSON.parse(dataRaw).status === "completed";
const d = JSON.parse(dataRaw);
ok = d.status === "completed";
if (!ok && typeof d.error === "string") detail = d.error;
} catch {
/* ignore */
}
} else if (event === "end") {
return ok;
return { ok, detail };
}
}
}
} catch {
return ok;
return { ok, detail };
}
return ok;
return { ok, detail };
}
export async function runWizard(): Promise<void> {
@@ -703,6 +716,29 @@ export async function runWizard(): Promise<void> {
if (status && !status.canProceedClean && status.occupants?.length) {
const owner = status.occupants.map((o) => o.command ?? `port ${o.port}`).join(", ");
const known = status.classification === "known";
// Show WHAT would be migrated (not just a count) so the operator can audit
// it before handing us their edge. Mirrors the dashboard takeover modal.
const sites = (pf.ok && Array.isArray(pf.data?.sites) ? pf.data.sites : []) as Array<{
serverNames?: string[];
ssl?: boolean;
target?: { kind?: string; url?: string; root?: string };
source?: string;
}>;
if (sites.length > 0) {
const lines = sites.map((st) => {
const host = (st.serverNames ?? []).join(", ") || "(no server_name)";
const dest = st.target?.kind === "static" ? `static: ${st.target?.root ?? ""}` : st.target?.url ?? "";
return `${chalk.bold(host)} → ${chalk.dim(dest)}${st.ssl ? chalk.green(" [TLS]") : ""}`;
});
note(lines.join("\n"), `Detected ${sites.length} site${sites.length === 1 ? "" : "s"} on ${owner}`);
}
const warns = (pf.ok && Array.isArray(pf.data?.warnings) ? pf.data.warnings : []) as string[];
if (warns.length > 0) {
log.warn(`${warns.length} config item${warns.length === 1 ? "" : "s"} won't migrate automatically:`);
for (const w of warns.slice(0, 8)) log.message(chalk.dim(`• ${w}`));
}
const choice = ensure(
await select({
message: known
@@ -755,10 +791,13 @@ export async function runWizard(): Promise<void> {
if (res.ok && res.data?.sessionId) {
const s2 = spinner();
s2.start("Issuing HTTPS certificate (OpenResty + Let's Encrypt)");
const done = await streamProvision(port, res.data.sessionId, s2);
const { ok: done, detail } = await streamProvision(port, res.data.sessionId, s2);
liveUrl = res.data.url ?? liveUrl;
if (done) s2.stop(`HTTPS ready: ${liveUrl}`);
else s2.stop("HTTPS isn't ready yet — it retries on reboot; the site serves over HTTP meanwhile.", 1);
else {
s2.stop("HTTPS isn't ready yet — it retries on reboot; the site serves over HTTP meanwhile.", 1);
if (detail) log.warn(detail);
}
} else {
log.warn(`Couldn't start domain provisioning: ${res.data?.error || "failed"}`);
}
@@ -17,6 +17,7 @@ import {
import type { Terminal } from "@xterm/xterm";
import BuildTerminal from "./BuildTerminal";
import { PortAdvisoryModal } from "./PortAdvisoryModal";
import { PromptDetails } from "./PromptDetails";
import { generateIcon } from "@/utils/icons";
import { useRouter } from "next/navigation";
import { encodeRepoSlug } from "@/utils/repoSlug";
@@ -110,33 +111,7 @@ const DeploymentProcessing: React.FC<DeploymentProcessingProps> = ({ onRedeploy
// and navigates to the new deployment (or re-enables on failure).
const [isRedeploying, setIsRedeploying] = useState(false);
const renderPromptDetails = useCallback((details?: Record<string, unknown>) => {
if (!details) return null;
const rows: Array<{ label: string; value: string | null }> = [
{ label: dp.promptDetails.port, value: details.port != null ? String(details.port) : null },
{ label: dp.promptDetails.process, value: typeof details.command === "string" ? details.command : null },
{ label: "PID", value: details.pid != null ? String(details.pid) : null },
{ label: "Systemd Unit", value: typeof details.systemdUnit === "string" ? details.systemdUnit : null },
{ label: dp.promptDetails.unitDescription, value: typeof details.systemdDescription === "string" ? details.systemdDescription : null },
{ label: dp.promptDetails.openshipDeployment, value: typeof details.deploymentId === "string" ? details.deploymentId : null },
].filter((row): row is { label: string; value: string } => Boolean(row.value));
if (rows.length === 0) return null;
return (
<div className="rounded-xl border border-border bg-muted/40 p-4 space-y-3">
{rows.map((row) => (
<div key={row.label} className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-muted-foreground">{row.label}</span>
<span className="text-sm text-foreground break-all">{row.value}</span>
</div>
))}
</div>
);
}, [dp]);
// ── Pipeline prompt modal (e.g. port conflict) ─────────────────────────
// ── Pipeline prompt modal (port conflict / edge takeover) ──────────────
useEffect(() => {
if (!state.pendingPrompt) return;
const { promptId, title, message, actions, details } = state.pendingPrompt;
@@ -153,7 +128,7 @@ const DeploymentProcessing: React.FC<DeploymentProcessingProps> = ({ onRedeploy
<p className="text-sm leading-relaxed text-muted-foreground">{message}</p>
</div>
{renderPromptDetails(details)}
<PromptDetails details={details} />
<div className="flex items-center justify-end gap-3 pt-2">
{actions.map((action) => {
@@ -184,7 +159,7 @@ const DeploymentProcessing: React.FC<DeploymentProcessingProps> = ({ onRedeploy
width: "560px",
maxWidth: "92vw",
});
}, [state.pendingPrompt, showModal, hideModal, respondToPrompt, renderPromptDetails]);
}, [state.pendingPrompt, showModal, hideModal, respondToPrompt]);
// Build domain for display
const endpointHosts = getPublicEndpointHosts(config.publicEndpoints, baseDomain, config.projectName);
@@ -0,0 +1,139 @@
"use client";
import React from "react";
import { Lock, ArrowRight, AlertTriangle } from "lucide-react";
import { useI18n, interpolate } from "@/components/i18n-provider";
/**
* Renders the `details` payload of a pipeline prompt. Two shapes:
*
* - port conflict → a port/PID/systemd-unit key list (a service already on the port).
* - edge conflict → the sites parsed from an existing reverse proxy on 80/443, so the
* operator can AUDIT exactly what a "migrate & take over" would import (and, via the
* warnings, what won't migrate automatically).
*
* Shared by DeploymentProcessing + ComposeDeploymentProcessing so both prompt modals
* surface the same information. `details` is untyped over the wire (Record) — narrow it here.
*/
type EdgeSite = {
serverNames: string[];
ssl: boolean;
target: { kind: "proxy"; url: string } | { kind: "static"; root: string };
tls?: { certPath: string; keyPath: string };
source?: string;
};
function asEdgeSites(details: Record<string, unknown>): EdgeSite[] {
const raw = details.sites;
if (!Array.isArray(raw)) return [];
return raw.filter(
(s): s is EdgeSite =>
!!s &&
typeof s === "object" &&
Array.isArray((s as { serverNames?: unknown }).serverNames) &&
typeof (s as { target?: unknown }).target === "object" &&
(s as { target: { kind?: unknown } }).target?.kind != null,
);
}
function asWarnings(details: Record<string, unknown>): string[] {
const raw = details.warnings;
return Array.isArray(raw) ? raw.filter((w): w is string => typeof w === "string") : [];
}
function targetLabel(site: EdgeSite, staticLabel: string): string {
return site.target.kind === "proxy" ? site.target.url : `${staticLabel} ${site.target.root}`;
}
export const PromptDetails: React.FC<{ details?: Record<string, unknown> }> = ({ details }) => {
const { t } = useI18n();
const dp = t.importProject.deploymentProcessing;
if (!details) return null;
const sites = asEdgeSites(details);
const warnings = asWarnings(details);
// ── Edge conflict: detected sites + un-migratable warnings ──────────────
if (sites.length > 0 || warnings.length > 0) {
return (
<div className="space-y-3">
{sites.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{interpolate(dp.promptDetails.sites.lead, { count: String(sites.length) })}
</p>
<div className="rounded-xl border border-border bg-muted/40 divide-y divide-border">
{sites.map((site, i) => (
<div key={`${site.serverNames.join(",")}-${i}`} className="flex items-center gap-2 p-3 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm font-medium text-foreground truncate">
{site.serverNames.join(", ")}
</span>
{site.ssl && (
<span className="inline-flex items-center gap-0.5 rounded bg-success/10 px-1.5 py-0.5 text-[10px] font-medium text-success shrink-0">
<Lock className="size-2.5" />
{dp.promptDetails.sites.tlsBadge}
</span>
)}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
<ArrowRight className="size-3 shrink-0" />
<span className="truncate font-mono">{targetLabel(site, dp.promptDetails.sites.staticLabel)}</span>
</div>
{site.source && (
<p className="text-[10px] text-muted-foreground/70 truncate mt-0.5">{site.source}</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{warnings.length > 0 && (
<div className="rounded-xl border border-warning/30 bg-warning/5 p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-xs font-medium text-warning">
<AlertTriangle className="size-3.5" />
{dp.promptDetails.sites.warningsTitle}
</div>
<ul className="space-y-1">
{warnings.map((w, i) => (
<li key={i} className="text-xs text-muted-foreground break-words">
{w}
</li>
))}
</ul>
</div>
)}
</div>
);
}
// ── Port conflict: key/value list of the occupying process ──────────────
const rows: Array<{ label: string; value: string | null }> = [
{ label: dp.promptDetails.port, value: details.port != null ? String(details.port) : null },
{ label: dp.promptDetails.process, value: typeof details.command === "string" ? details.command : null },
{ label: "PID", value: details.pid != null ? String(details.pid) : null },
{ label: "Systemd Unit", value: typeof details.systemdUnit === "string" ? details.systemdUnit : null },
{ label: dp.promptDetails.unitDescription, value: typeof details.systemdDescription === "string" ? details.systemdDescription : null },
{ label: dp.promptDetails.openshipDeployment, value: typeof details.deploymentId === "string" ? details.deploymentId : null },
].filter((row): row is { label: string; value: string } => Boolean(row.value));
if (rows.length === 0) return null;
return (
<div className="rounded-xl border border-border bg-muted/40 p-4 space-y-3">
{rows.map((row) => (
<div key={row.label} className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-muted-foreground">{row.label}</span>
<span className="text-sm text-foreground break-all">{row.value}</span>
</div>
))}
</div>
);
};
export default PromptDetails;
@@ -6,6 +6,7 @@ import { Loader2, CheckCircle2, XCircle, SlidersHorizontal } from "lucide-react"
import ComposeSidebar from "./ComposeSidebar";
import BuildTerminal from "../BuildTerminal";
import { PortAdvisoryModal } from "../PortAdvisoryModal";
import { PromptDetails } from "../PromptDetails";
import { generateIcon } from "@/utils/icons";
import { useRouter } from "next/navigation";
import { useDeployment } from "@/context/DeploymentContext";
@@ -158,7 +159,7 @@ const ComposeDeploymentProcessing: React.FC<Props> = ({ onRedeploy }) => {
// ── Pipeline prompt modal ──────────────────────────────────────────────
useEffect(() => {
if (!state.pendingPrompt) return;
const { promptId, title, message, actions } = state.pendingPrompt;
const { promptId, title, message, actions, details } = state.pendingPrompt;
if (promptModalRef.current === promptId) return;
promptModalRef.current = promptId;
@@ -171,6 +172,9 @@ const ComposeDeploymentProcessing: React.FC<Props> = ({ onRedeploy }) => {
<h3 className="text-xl font-bold text-foreground">{title}</h3>
<p className="text-sm leading-relaxed text-muted-foreground">{message}</p>
</div>
<PromptDetails details={details} />
<div className="flex items-center justify-end gap-3 pt-2">
{actions.map((action) => {
const variant = (action.variant || "secondary") as "secondary" | "danger" | "primary";
@@ -28,6 +28,7 @@ import {
type DiscoveredStack,
type DiscoveredGroup,
type DiscoveredService,
type OpenshipProjectGroup,
type MigrationRun,
type MigrationStatus,
} from "@/lib/api";
@@ -313,6 +314,12 @@ export function ServerMigrationWizard({
// ── Derived ──────────────────────────────────────────────────────────────
const adoptable = Boolean(stack?.adoptable);
// Openship projects on the server that this instance doesn't know → re-importable.
const orphanedOpenship = useMemo(
() => stack?.openshipProjects?.filter((p) => !p.knownHere) ?? [],
[stack],
);
const hasReimport = orphanedOpenship.length > 0;
const sameServer = selectedId === targetId;
// Cross-server can't move a locally-built image (not in a registry) — the API
// blocks it with the exact service names. Surface the caveat up front when a
@@ -691,14 +698,35 @@ export function ServerMigrationWizard({
{/* Idle + loading keep the illustration (loading just pulses it). */}
{!stack && !error && <EmptyHint scanning={scanning} status={scanStatus} />}
{/* Scanned but nothing adoptable → stay compact, show a "nothing
found" state (not a giant empty modal). */}
{stack && !adoptable && <NoResults message={m.discover.nothing} />}
{/* Scanned but nothing adoptable AND nothing to re-import → compact
"nothing found" (not a giant empty modal). */}
{stack && !adoptable && !hasReimport && <NoResults message={m.discover.nothing} />}
{/* Only Openship projects to re-import (no generic candidates): show
the re-import section on its own. */}
{stack && !adoptable && hasReimport && (
<div className="h-full min-h-0 overflow-y-auto pr-1">
<OpenshipReimportSection
serverId={selectedId ?? ""}
orphaned={orphanedOpenship}
alreadyManaged={stack.alreadyManaged}
onOpen={(pid) => router.push(`/projects/${pid}`)}
/>
</div>
)}
{adoptable && stack && active && (
<div className="flex gap-5 h-full min-h-0">
{/* LEFT: discovered groups */}
<div className="flex-[3] min-w-0 overflow-y-auto pr-1 space-y-4">
{hasReimport && (
<OpenshipReimportSection
serverId={selectedId ?? ""}
orphaned={orphanedOpenship}
alreadyManaged={stack.alreadyManaged}
onOpen={(pid) => router.push(`/projects/${pid}`)}
/>
)}
{stack.groups.map((group) => (
<ServiceGroup
key={groupKey(group)}
@@ -925,6 +953,128 @@ function NoResults({ message, isError }: { message: string; isError?: boolean })
);
}
/**
* Openship projects recovered from the server (matched by the `openship.project`
* label + the on-server manifest) that this instance doesn't know — DB reset
* (DR) or a server from another instance. Re-import rebuilds the project records
* PRESERVING the original id so the running containers re-attach; it's records
* only (no move/redeploy), so a "redeploy to finalize" note follows.
*/
function OpenshipReimportSection({
serverId,
orphaned,
alreadyManaged,
onOpen,
}: {
serverId: string;
orphaned: OpenshipProjectGroup[];
alreadyManaged: number;
onOpen: (projectId: string) => void;
}) {
const { t } = useI18n();
const m = t.migration.reimport;
const [names, setNames] = useState<Record<string, string>>({});
const [busy, setBusy] = useState<string | null>(null);
const [done, setDone] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const reimport = async (p: OpenshipProjectGroup) => {
setBusy(p.projectId);
setErrors((e) => ({ ...e, [p.projectId]: "" }));
try {
const res = await dockerMigrationApi.reimport({
serverId,
projectId: p.projectId,
projectName: (names[p.projectId] ?? p.suggestedName).trim() || undefined,
});
setDone((d) => ({ ...d, [p.projectId]: res.projectId }));
} catch (err) {
setErrors((e) => ({ ...e, [p.projectId]: getApiErrorMessage(err, m.failed) }));
} finally {
setBusy(null);
}
};
return (
<section className="space-y-3 rounded-xl border border-info/30 bg-info/[0.06] p-4">
<div className="flex items-center gap-2">
<AppLogo className="size-4" />
<h3 className="text-sm font-semibold text-foreground">{m.title}</h3>
<span className="rounded-md bg-info/15 px-1.5 py-0.5 text-[11px] font-medium text-info">
{orphaned.length}
</span>
</div>
<p className="text-[13px] text-muted-foreground">{m.intro}</p>
<div className="grid grid-cols-1 gap-2 xl:grid-cols-2 items-stretch">
{orphaned.map((p) => {
const doneId = done[p.projectId];
const err = errors[p.projectId];
return (
<div
key={p.projectId}
className="flex h-full flex-col gap-2 rounded-lg border border-border/60 bg-card/60 p-3"
>
{doneId ? (
<div className="flex h-full flex-col justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm text-success">
<CheckCircle2 className="size-4 shrink-0" />
<span className="font-medium">{m.reimported}</span>
</div>
<button
type="button"
onClick={() => onOpen(doneId)}
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
{m.openProject}
<ArrowRight className="size-3.5" />
</button>
</div>
) : (
<>
<input
value={names[p.projectId] ?? p.suggestedName}
onChange={(e) => setNames((n) => ({ ...n, [p.projectId]: e.target.value }))}
className="w-full rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm text-foreground outline-none focus:border-info"
placeholder={p.suggestedName}
/>
<div className="text-xs text-muted-foreground">
{interpolate(m.services, { n: String(p.services.length) })}
{p.domains && p.domains.length > 0 ? ` · ${p.domains.join(", ")}` : ""}
</div>
{err && <p className="text-xs text-danger">{err}</p>}
<button
type="button"
disabled={busy === p.projectId || !serverId}
onClick={() => reimport(p)}
className="mt-auto inline-flex items-center justify-center gap-1.5 rounded-lg border border-info/50 bg-info/10 px-3 py-1.5 text-sm font-medium text-info hover:bg-info/20 transition-colors disabled:opacity-50"
>
{busy === p.projectId ? (
<>
<Loader2 className="size-3.5 animate-spin" />
{m.working}
</>
) : (
m.action
)}
</button>
</>
)}
</div>
);
})}
</div>
{alreadyManaged > 0 && (
<p className="text-xs text-muted-foreground">
{interpolate(m.alreadyManaged, { n: String(alreadyManaged) })}
</p>
)}
<p className="text-xs text-muted-foreground">{m.finalizeNote}</p>
</section>
);
}
function ServiceGroup({
group,
activeProject,
@@ -265,7 +265,13 @@
"port": "المنفذ",
"process": "العملية",
"unitDescription": "وصف الوحدة",
"openshipDeployment": "نشر Openship"
"openshipDeployment": "نشر Openship",
"sites": {
"lead": "يخدم الوكيل الحالي {count} موقعًا في الوقت الحالي. يؤدي الترحيل إلى استيرادها إلى Openship قبل الاستيلاء على المنفذين 80/443:",
"tlsBadge": "TLS",
"staticLabel": "ثابت:",
"warningsTitle": "تعذّر ترحيلها تلقائيًا — أعد إضافتها يدويًا:"
}
},
"title": {
"cancelled": "تم إلغاء النشر",
@@ -64,6 +64,18 @@
"proxyExcluded": "وكيل عكسي ({ports}) — لم يُستورد؛ حافة Openship تحل محله.",
"edgePortReserved": "المنافذ {ports} محجوزة لحافة Openship؛ لا تُنشر."
},
"reimport": {
"title": "مشاريع Openship المكتشفة",
"intro": "مشاريع Openship هذه تعمل على هذا الخادم لكنها غير موجودة في هذا النظام (قاعدة بيانات مُعاد تعيينها، أو خادم من نظام Openship آخر). أعد الاستيراد لاستعادتها — يُحتفظ بالمعرّف الأصلي لتُعاد الحاويات الحية للاتصال.",
"services": "{n} خدمة",
"action": "إعادة الاستيراد",
"working": "جارٍ إعادة الاستيراد…",
"reimported": "تمت إعادة الاستيراد",
"openProject": "فتح المشروع",
"alreadyManaged": "{n} حاوية يديرها هذا النظام بالفعل.",
"finalizeNote": "تستعيد إعادة الاستيراد سجلات المشروع؛ أعد النشر من المشروع لجعل الحاويات العاملة تحت الإدارة بالكامل.",
"failed": "فشلت إعادة الاستيراد"
},
"scanFailed": "فشل فحص Docker",
"adoptFailed": "تعذّر إنشاء المشروع",
"entry": {
@@ -263,7 +263,13 @@
"port": "Port",
"process": "Prozess",
"unitDescription": "Unit-Beschreibung",
"openshipDeployment": "Openship-Bereitstellung"
"openshipDeployment": "Openship-Bereitstellung",
"sites": {
"lead": "Der vorhandene Proxy bedient derzeit {count} Site(s). Beim Migrieren werden sie in Openship importiert, bevor es die Ports 80/443 übernimmt:",
"tlsBadge": "TLS",
"staticLabel": "statisch:",
"warningsTitle": "Konnten nicht automatisch migriert werden — bitte manuell erneut hinzufügen:"
}
},
"title": {
"cancelled": "Bereitstellung abgebrochen",
@@ -265,7 +265,13 @@
"port": "Port",
"process": "Process",
"unitDescription": "Unit Description",
"openshipDeployment": "Openship Deployment"
"openshipDeployment": "Openship Deployment",
"sites": {
"lead": "{count} site(s) are currently served by the existing proxy. Migrating imports them into Openship before it takes over ports 80/443:",
"tlsBadge": "TLS",
"staticLabel": "static:",
"warningsTitle": "Couldn't be migrated automatically — re-add these manually:"
}
},
"portAdvisory": {
"title": "Is that the right port?",
@@ -95,6 +95,18 @@
"nEnv": "{n} env vars",
"alreadyManaged": "{count} container(s) already managed by Openship were skipped."
},
"reimport": {
"title": "Openship projects found",
"intro": "These Openship projects are running on this server but aren't in this instance (a reset database, or a server from another Openship). Re-import to recover them — the original id is preserved so the live containers re-attach.",
"services": "{n} service(s)",
"action": "Re-import",
"working": "Re-importing…",
"reimported": "Re-imported",
"openProject": "Open project",
"alreadyManaged": "{n} container(s) are already managed by this instance.",
"finalizeNote": "Re-import restores the project records; redeploy from the project to bring the running containers fully under management.",
"failed": "Re-import failed"
},
"scanFailed": "Docker inspection failed",
"adoptFailed": "Couldn't create the project",
"entry": {
@@ -263,7 +263,13 @@
"port": "Puerto",
"process": "Proceso",
"unitDescription": "Descripción de la unidad",
"openshipDeployment": "Despliegue de Openship"
"openshipDeployment": "Despliegue de Openship",
"sites": {
"lead": "El proxy existente sirve actualmente {count} sitio(s). Al migrar, se importan a Openship antes de tomar el control de los puertos 80/443:",
"tlsBadge": "TLS",
"staticLabel": "estáticos:",
"warningsTitle": "No se pudieron migrar automáticamente — vuelve a añadirlos manualmente:"
}
},
"title": {
"cancelled": "Despliegue cancelado",
@@ -265,7 +265,13 @@
"port": "Port",
"process": "Processus",
"unitDescription": "Description de l'unité",
"openshipDeployment": "Déploiement Openship"
"openshipDeployment": "Déploiement Openship",
"sites": {
"lead": "Le proxy existant dessert actuellement {count} site(s). La migration les importe dans Openship avant qu'il ne prenne le contrôle des ports 80/443 :",
"tlsBadge": "TLS",
"staticLabel": "statique :",
"warningsTitle": "N'ont pas pu être migrés automatiquement — à rajouter manuellement :"
}
},
"title": {
"cancelled": "Déploiement annulé",
@@ -88,6 +88,18 @@
"nEnv": "{n} variables d'env",
"alreadyManaged": "{count} conteneur(s) déjà géré(s) par Openship ont été ignorés."
},
"reimport": {
"title": "Projets Openship détectés",
"intro": "Ces projets Openship s'exécutent sur ce serveur mais sont absents de cette instance (base de données réinitialisée, ou serveur provenant d'une autre instance Openship). Ré-importez-les pour les récupérer — l'identifiant d'origine est conservé pour que les conteneurs actifs se rattachent.",
"services": "{n} service(s)",
"action": "Ré-importer",
"working": "Ré-importation…",
"reimported": "Ré-importé",
"openProject": "Ouvrir le projet",
"alreadyManaged": "{n} conteneur(s) déjà géré(s) par cette instance.",
"finalizeNote": "La ré-importation restaure les enregistrements du projet ; redéployez depuis le projet pour placer les conteneurs entièrement sous gestion.",
"failed": "Échec de la ré-importation"
},
"scanFailed": "Échec de l'inspection Docker",
"adoptFailed": "Impossible de créer le projet",
"entry": {
@@ -263,7 +263,13 @@
"port": "ポート",
"process": "プロセス",
"unitDescription": "ユニットの説明",
"openshipDeployment": "Openship デプロイ"
"openshipDeployment": "Openship デプロイ",
"sites": {
"lead": "既存のプロキシは現在 {count} 件のサイトを配信しています。移行すると、ポート 80/443 を引き継ぐ前にそれらを Openship にインポートします:",
"tlsBadge": "TLS",
"staticLabel": "静的:",
"warningsTitle": "自動的に移行できませんでした — 手動で再追加してください:"
}
},
"title": {
"cancelled": "デプロイをキャンセルしました",
@@ -263,7 +263,13 @@
"port": "Porta",
"process": "Processo",
"unitDescription": "Descrição da Unidade",
"openshipDeployment": "Implantação Openship"
"openshipDeployment": "Implantação Openship",
"sites": {
"lead": "O proxy existente serve atualmente {count} site(s). A migração os importa para o Openship antes de assumir as portas 80/443:",
"tlsBadge": "TLS",
"staticLabel": "estático:",
"warningsTitle": "Não puderam ser migrados automaticamente — adicione-os manualmente:"
}
},
"title": {
"cancelled": "Implantação Cancelada",
@@ -263,7 +263,13 @@
"port": "端口",
"process": "进程",
"unitDescription": "单元描述",
"openshipDeployment": "Openship 部署"
"openshipDeployment": "Openship 部署",
"sites": {
"lead": "现有代理当前提供 {count} 个站点。迁移会先将它们导入 Openship,然后接管 80/443 端口:",
"tlsBadge": "TLS",
"staticLabel": "静态:",
"warningsTitle": "无法自动迁移 — 请手动重新添加:"
}
},
"title": {
"cancelled": "部署已取消",
+1
View File
@@ -348,6 +348,7 @@ export const endpoints = {
scan: "migration/scan",
scanStream: "migration/scan/stream",
adopt: "migration/adopt",
reimport: "migration/reimport",
preview: "migration/preview",
migrate: "migration/migrate",
migration: (id: string) => `migration/migrations/${id}`,
+2
View File
@@ -58,6 +58,8 @@ export type {
DiscoveredGroup,
DiscoveredService,
DiscoveredVolumeMount,
OpenshipProjectGroup,
ReimportResult,
AdoptResult,
MigrationPreview,
MigrationPreviewService,
@@ -40,6 +40,25 @@ export interface DiscoveredGroup {
services: DiscoveredService[];
}
/** An Openship project recovered from the server (see api docker-reconcile.ts). */
export interface OpenshipProjectGroup {
projectId: string;
suggestedName: string;
slug?: string;
domains?: string[];
source?: {
gitProvider?: string | null;
gitOwner?: string | null;
gitRepo?: string | null;
gitBranch?: string | null;
};
runtimeMode?: string | null;
/** true = already exists in this instance's DB (not re-importable, just counted). */
knownHere: boolean;
deploymentId?: string;
services: DiscoveredService[];
}
export interface DiscoveredStack {
serverId: string;
composeProjects: string[];
@@ -49,7 +68,18 @@ export interface DiscoveredStack {
networks: Array<{ name: string; driver: string }>;
warnings: string[];
adoptable: boolean;
/** Live containers already managed by a project in this instance's DB (count). */
alreadyManaged: number;
/** Openship projects found on the server; `knownHere: false` are re-importable. */
openshipProjects: OpenshipProjectGroup[];
}
export interface ReimportResult {
success: boolean;
projectId: string;
slug: string;
reimported: string[];
deferredDeployment: true;
}
export interface AdoptResult {
@@ -204,6 +234,15 @@ export const dockerMigrationApi = {
adopt: (input: { serverId: string; projectName: string; serviceNames: string[] }) =>
api.post<AdoptResult>(endpoints.dockerMigration.adopt, input),
/** Re-import an orphaned Openship project (DR / cross-instance), preserving its id.
* Records only — the user redeploys from the project to finalize live state. */
reimport: (input: {
serverId: string;
projectId: string;
projectName?: string;
serviceNames?: string[];
}) => api.post<ReimportResult>(endpoints.dockerMigration.reimport, input),
/** Read-only preview of a full migration to a (possibly different) server. */
preview: (input: {
sourceServerId: string;
+63 -6
View File
@@ -14,7 +14,7 @@
*/
import { execFileSync } from "node:child_process";
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -84,6 +84,10 @@ function main(): void {
// staged into resources/node_modules below and resolved at runtime via
// NODE_PATH (services.ts). cpu-features is ssh2's optional native dep whose
// .node binding can't be embedded either; ssh2 guards it and falls back.
// NOTE: runtime resolution of these externals only works on Bun < 1.3.4 —
// Bun 1.3.4 regressed --compile external resolution to the $bunfs root
// (oven-sh/bun #25500, issue #111). Build is pinned pre-1.3.4 (.bun-version)
// and the canary in step 1c fails the build if a bump reintroduces the bug.
// --target pins the output arch so we can cross-compile x64 on an arm64 host.
execFileSync(
BUN,
@@ -110,11 +114,21 @@ function main(): void {
process.stdout.write(` ${API_BIN}: ${sizeOf(out)}\n`);
});
// 1b. Stage the SSH/Docker native stack (externalized above) as a real
// node_modules the compiled binary resolves at runtime via NODE_PATH
// (set in services.ts). npm produces a hoisted tree with all transitive
// deps (asn1, bcrypt-pbkdf, …); versions track packages/adapters so the
// shipped copy matches what the API was built against.
// 1b. Stage the SSH/Docker stack (externalized above) as a real node_modules
// the compiled binary resolves at runtime via NODE_PATH (set in
// services.ts). npm produces a hoisted tree with all transitive deps
// (asn1, bcrypt-pbkdf, docker-modem, …); versions track packages/adapters
// so the shipped copy matches what the API was built against.
//
// `--omit=optional` is LOAD-BEARING for cross-arch correctness: the only
// native module in this tree is `cpu-features` (ssh2's optional CPU probe),
// and it has NO prebuilds — node-gyp compiles it for the BUILD-HOST arch,
// so a cross-built x64 dmg on the arm64 runner would ship an arm64
// `cpu-features.node`. Dropping it makes the tree 100% arch-independent
// (zero `.node` files → correct on x64 AND arm64, win/mac/linux); ssh2
// falls back to pure-JS/WASM crypto (negligible for control-plane SSH),
// dockerode is pure JS. This is why every platform's artifact ships a
// complete, working module set with nothing arch-mismatched.
step("staging ssh2 + dockerode → resources/node_modules", () => {
const adapters = JSON.parse(
readFileSync(join(REPO_ROOT, "packages/adapters/package.json"), "utf8"),
@@ -126,6 +140,7 @@ function main(): void {
"--prefix",
RESOURCES,
"--omit=dev",
"--omit=optional",
"--no-audit",
"--no-fund",
"--no-package-lock",
@@ -139,6 +154,48 @@ function main(): void {
);
});
// 1c. CANARY for oven-sh/bun #25500 (issue #111). Bun 1.3.4 regressed
// `--compile --external` resolution to the virtual $bunfs root, ignoring
// NODE_PATH/CWD — so the compiled API silently can't load the externalized
// ssh2 and the desktop dies at startup with "Cannot find package 'ssh2'".
// Build is pinned pre-1.3.4 (.bun-version); this compiles a tiny probe with
// the SAME bun + --external and confirms ssh2 still resolves from the staged
// node_modules, so any future Bun bump that reintroduces the bug FAILS THE
// BUILD instead of shipping a broken app. Probe is host-arch (not
// BUN_TARGET) so it runs on the build machine — the regression is version-,
// not arch-, specific.
step("verifying externalized ssh2 resolves in a compiled binary (bun #25500 canary)", () => {
const probeSrc = join(RESOURCES, "__ssh2-probe.ts");
const probeBin = join(RESOURCES, isWin ? "__ssh2-probe.exe" : "__ssh2-probe");
writeFileSync(
probeSrc,
'const m: any = await import("ssh2");\n' +
'if (typeof m.Client !== "function") { console.error("NO_CLIENT"); process.exit(1); }\n' +
'console.log("SSH2_PROBE_OK");\n',
);
try {
execFileSync(BUN, ["build", probeSrc, "--compile", "--external", "ssh2", "--outfile", probeBin], {
cwd: REPO_ROOT,
stdio: "inherit",
});
const out = execFileSync(probeBin, [], {
env: { ...process.env, NODE_PATH: join(RESOURCES, "node_modules") },
encoding: "utf8",
});
if (!out.includes("SSH2_PROBE_OK")) throw new Error(`probe did not confirm ssh2 (got: ${out.trim()})`);
} catch (err) {
throw new Error(
"ssh2 external-resolution canary FAILED — the compiled binary can't load ssh2 from node_modules. " +
"This is the Bun #25500 --compile regression (>=1.3.4). Keep .bun-version on a pre-1.3.4 release " +
`until it is fixed upstream. Underlying: ${(err as Error).message}`,
);
} finally {
rmSync(probeSrc, { force: true });
rmSync(probeBin, { force: true });
}
process.stdout.write(" ssh2 resolves from node_modules ✓\n");
});
// 2. Dashboard → build fresh with prod/local env, then copy the Next
// standalone OUTPUT. A release is always local + production.
step("building dashboard (next build, standalone)", () => {
+6 -3
View File
@@ -291,9 +291,12 @@ export async function startLocalServices(internalToken: string): Promise<void> {
BETTER_AUTH_SECRET: authSecret,
INTERNAL_TOKEN: internalToken,
// The compiled API binary loads ssh2/dockerode (externalized from the
// --compile bundle) from the staged Resources/node_modules — without this
// the SSH/Docker connect hangs. Verified: Bun compiled binaries honor
// NODE_PATH for external module resolution.
// --compile bundle) from the staged Resources/node_modules via NODE_PATH.
// This ONLY works on Bun < 1.3.4: Bun 1.3.4 regressed `--compile --external`
// resolution to the virtual $bunfs root, ignoring NODE_PATH/CWD (oven-sh/bun
// #25500), which broke desktop startup with "Cannot find package 'ssh2'"
// (issue #111). The build pins a pre-1.3.4 Bun (.bun-version) + a stage.ts
// canary — do NOT bump Bun past that until #25500 is fixed upstream.
NODE_PATH: nodeModulesDir,
});
+1 -1
View File
@@ -3,6 +3,6 @@
"type": "project",
"require": {
"php": "^8.2",
"laravel/framework": "^11.0"
"laravel/framework": "^12.61.1"
}
}
+1 -1
View File
@@ -135,7 +135,7 @@ export type {
SystemLog,
SystemLogCallback,
} from "./system/types";
export type { ImportedSite, ProxyScanResult } from "./system/types";
export type { EdgeConflictDetails, ImportedSite, ProxyScanResult } from "./system/types";
export {
classifyProxy,
EdgeConflictError,
@@ -62,3 +62,37 @@ describe("openresty install plan — #86 codename handling", () => {
expect(cmd(linux({ distro: "debian" }))).toContain("REPO=debian");
});
});
/**
* Regression guard for GitHub #109: openresty.org publishes aarch64 debs under a
* SEPARATE path (openresty.org/package/arm64/<repo>); the default path is
* amd64-only. The apt install must resolve the base URL by the host's dpkg arch
* and tag the deb line with `arch=`, or `apt-get install openresty` finds no
* candidate and aborts the whole setup on ARM64. Arch is decided at RUNTIME
* (shell), so both the direct apt branch and the runtime-probe branch carry it.
*/
describe("openresty install plan — #109 arm64 apt repo", () => {
for (const [name, profile] of [
["apt/ubuntu", linux({ packageManager: "apt", distro: "ubuntu" })],
["apt/debian", linux({ packageManager: "apt", distro: "debian" })],
["runtime-probe", linux({ packageManager: "none" })],
] as const) {
describe(name, () => {
const c = cmd(profile);
it("detects the host arch via dpkg", () => {
expect(c).toContain("dpkg --print-architecture");
});
it("switches to the arm64 package path on aarch64, keeps amd64 default", () => {
expect(c).toContain("http://openresty.org/package/arm64/$REPO");
expect(c).toContain('OR_BASE="http://openresty.org/package/$REPO"');
});
it("probes the arch-correct base and tags the deb line with arch=", () => {
expect(c).toContain('"$OR_BASE/dists/$c/Release"');
expect(c).toMatch(/deb \[arch=\$OR_ARCH signed-by=[^\]]*openresty\.gpg\] \$OR_BASE \$OR_CODENAME main/);
});
});
}
});
+10 -2
View File
@@ -95,6 +95,14 @@ function rsyncInstallPlan(profile: EnvironmentProfile): InstallPlan {
* deviate. Expects $REPO set to "ubuntu" or "debian"; assumes wget is installed.
*/
const OPENRESTY_APT_SOURCES: string[] = [
// ARM64: openresty.org publishes aarch64 debs under a SEPARATE path
// (openresty.org/package/arm64/<repo>); the default path is amd64-only, so on
// aarch64 `apt-get install openresty` finds no candidate and `set -e` aborts
// the whole setup (issue #109). Switch the base URL by arch and tag the deb
// line with `arch=` so apt fetches the right index. `dpkg --print-architecture`
// returns exactly amd64/arm64 (matches OpenResty's path + arch qualifier).
'OR_ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)"',
'if [ "$OR_ARCH" = arm64 ]; then OR_BASE="http://openresty.org/package/arm64/$REPO"; else OR_BASE="http://openresty.org/package/$REPO"; fi',
// `|| REPO_CODENAME=""` so a failed substitution (no lsb_release AND no
// /etc/os-release, e.g. a stripped container) doesn't trip `set -e` before
// the empty-var fallback below can pick a default.
@@ -102,11 +110,11 @@ const OPENRESTY_APT_SOURCES: string[] = [
'if [ "$REPO" = debian ]; then OR_FALLBACKS="bookworm bullseye"; else OR_FALLBACKS="noble jammy focal"; fi',
'OR_CODENAME=""',
'for c in $REPO_CODENAME $OR_FALLBACKS; do',
' if wget -q --spider --tries=2 --timeout=15 "http://openresty.org/package/$REPO/dists/$c/Release"; then OR_CODENAME="$c"; break; fi',
' if wget -q --spider --tries=2 --timeout=15 "$OR_BASE/dists/$c/Release"; then OR_CODENAME="$c"; break; fi',
'done',
'if [ -z "$OR_CODENAME" ]; then case "$REPO" in debian) OR_CODENAME=bookworm ;; *) OR_CODENAME=noble ;; esac; fi',
'[ "$OR_CODENAME" = "$REPO_CODENAME" ] || echo "[openresty] apt repo has no codename $REPO_CODENAME; using nearest supported LTS $OR_CODENAME" >&2',
'echo "deb [signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/$REPO $OR_CODENAME main" > /etc/apt/sources.list.d/openresty.list',
'echo "deb [arch=$OR_ARCH signed-by=/usr/share/keyrings/openresty.gpg] $OR_BASE $OR_CODENAME main" > /etc/apt/sources.list.d/openresty.list',
];
function openrestyInstallPlan(profile: EnvironmentProfile): InstallPlan {
+1 -1
View File
@@ -42,7 +42,7 @@ export {
probeEdge,
stopTargetsForStatus,
} from "./edge-preflight";
export type { ImportedSite, ProxyScanResult } from "./types";
export type { EdgeConflictDetails, ImportedSite, ProxyScanResult } from "./types";
export { scanImportableSites, canImportProxy } from "./proxy-import";
export {
runEdgeTakeover,
+18 -9
View File
@@ -7,7 +7,7 @@
*/
import type { CommandExecutor, LogEntry } from "../types";
import type { InstallerConfig, InstallResult, SystemLogCallback, SystemLog } from "./types";
import type { EdgeConflictDetails, InstallerConfig, InstallResult, SystemLogCallback, SystemLog } from "./types";
import { systemCatalog } from "./catalog";
import { resolveEnvironment, type EnvironmentProfile } from "./environment";
import { elevatedExecutor } from "./elevated-executor";
@@ -287,16 +287,25 @@ async function ensureEdgeClear(
}
const migratable = scan && scan.sites.length > 0;
// Openship terminates TLS + routes on its own OpenResty edge, so it must own
// 80/443. Spell that out — the operator is choosing to hand their load
// balancer to us, and "migrate" imports the existing sites first so nothing
// they're serving goes dark.
const message = migratable
? `Openship needs ports 80 and 443, but ${owner} is already serving them ` +
`(${scan!.sites.length} site${scan!.sites.length === 1 ? "" : "s"}). Migrate those sites ` +
`into Openship and take over, just stop it and take over, or cancel?`
? `Openship runs its own load balancer (OpenResty) on ports 80 and 443, but ${owner} is ` +
`already serving them (${scan!.sites.length} site${scan!.sites.length === 1 ? "" : "s"}). ` +
`Migrate those sites into Openship and take over, just stop it and take over, or cancel?`
: known
? `Openship needs ports 80 and 443, but ${owner} is already serving them. ` +
`Stop it and take over, or cancel and leave it running?`
: `Openship needs ports 80 and 443, but ${owner} is already using them and ` +
`we can't identify it. Stop it and take over, or cancel and leave it running?`;
? `Openship runs its own load balancer (OpenResty) on ports 80 and 443, but ${owner} is ` +
`already serving them. Stop it and take over, or cancel and leave it running?`
: `Openship runs its own load balancer (OpenResty) on ports 80 and 443, but ${owner} is ` +
`already using them and we can't identify it. Stop it and take over, or cancel and leave it running?`;
const details: EdgeConflictDetails = {
edge: status,
sites: scan?.sites ?? [],
warnings: scan?.warnings ?? [],
};
const action = await config.promptUser({
promptId: "edge_conflict",
title: known ? "Existing reverse proxy detected" : "Ports 80/443 are in use",
@@ -308,7 +317,7 @@ async function ensureEdgeClear(
{ id: "override", label: "Stop it & take over", variant: "danger" },
{ id: "cancel", label: "Cancel", variant: "secondary" },
],
details: { edge: status, sites: scan?.sites ?? [], warnings: scan?.warnings ?? [] },
details,
});
if (action === "migrate" && scan) {
+15
View File
@@ -211,6 +211,21 @@ export interface ProxyScanResult {
warnings: string[];
}
/**
* The `details` payload of an `edge_conflict` prompt (and the shape surfaced when
* a non-interactive install halts on an occupied edge). Lets every consumer — the
* dashboard takeover modal, the CLI preflight, the headless "re-run to take over"
* message — render the SAME audit data: what proxy holds 80/443 and exactly which
* sites a migrate would import.
*/
export type EdgeConflictDetails = {
edge: EdgeStatus;
/** Sites parsed from the foreign proxy's config (empty for takeover-only proxies). */
sites: ImportedSite[];
/** Config the scan couldn't interpret — shown so the operator knows what WON'T migrate. */
warnings: string[];
};
// ─── Runtime mode ────────────────────────────────────────────────────────────
export type RuntimeMode = "docker" | "bare";
+4 -2
View File
@@ -30,12 +30,14 @@ export type DesktopUpdateCheck =
* Installer asset name the release pipeline publishes for a platform/arch.
* Must match `.github/workflows/release.yml` exactly: macOS ships per-arch dmgs,
* Windows a single x64 zip (NOT a Squirrel Setup.exe — forge uses maker-zip),
* Linux a single AppImage. Returns null for an unknown platform.
* Linux a per-arch AppImage — x64 keeps the legacy `Openship.AppImage` name
* (so already-installed x64 clients keep auto-updating), arm64 is a distinct
* asset. Returns null for an unknown platform.
*/
export function desktopAssetName(platform: string, arch: string): string | null {
if (platform === "darwin") return arch === "arm64" ? "Openship-arm64.dmg" : "Openship-x64.dmg";
if (platform === "win32") return "Openship-win32-x64.zip";
if (platform === "linux") return "Openship.AppImage";
if (platform === "linux") return arch === "arm64" ? "Openship-arm64.AppImage" : "Openship.AppImage";
return null;
}
@@ -26,6 +26,7 @@ describe("desktopAssetName", () => {
expect(desktopAssetName("darwin", "x64")).toBe("Openship-x64.dmg");
expect(desktopAssetName("win32", "x64")).toBe("Openship-win32-x64.zip");
expect(desktopAssetName("linux", "x64")).toBe("Openship.AppImage");
expect(desktopAssetName("linux", "arm64")).toBe("Openship-arm64.AppImage");
expect(desktopAssetName("aix", "x64")).toBeNull();
});
});
+7 -3
View File
@@ -222,9 +222,13 @@ export function createProjectRepo(db: Database) {
};
},
async create(data: Omit<NewProject, "id">) {
const id = generateId("proj");
const row = { id, ...data };
async create(data: Omit<NewProject, "id"> & { id?: string }) {
// `id` is normally generated, but re-import (recovering an Openship project
// from a server's `.openship/manifest.json`) passes the ORIGINAL id so the
// still-running containers' `openship.project` labels re-attach immediately.
const { id: providedId, ...rest } = data;
const id = providedId ?? generateId("proj");
const row = { id, ...rest };
await db.insert(project).values(row);
return { ...row, createdAt: new Date(), updatedAt: new Date() } as Project;
},