mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
fix build issue for assests missing
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
1.3.10
|
||||
1.3.3
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
+5
-1
@@ -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": "部署已取消",
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^11.0"
|
||||
"laravel/framework": "^12.61.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user