Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions src/deploy/deploy-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,17 @@ function validateDisplayName(displayName: string): void {
if (displayName.length > DISPLAY_NAME_MAX) throw new Error(`display name too long (max ${DISPLAY_NAME_MAX} chars)`);
}

function deploymentEntrypoint(d: Deployment | null): string | undefined {
if (!d) return undefined;
return d.versions.find((v) => v.version === d.currentVersion)?.entrypoint || undefined;
}

function requiredEntrypoint(input: string | undefined, d: Deployment | null): string {
const entrypoint = input ?? deploymentEntrypoint(d);
if (!entrypoint) throw new Error('publish requires an entrypoint, e.g. "node server.js"');
return entrypoint;
}

export function createDeployService(deps: DeployServiceDeps): DeployService {
const leaderLease = deps.leaderLease ?? createNoopLeaderLease();
const advisoryLock = deps.advisoryLock ?? createNoopAdvisoryLock();
Expand Down Expand Up @@ -572,9 +583,10 @@ export function createDeployService(deps: DeployServiceDeps): DeployService {
resource: existing.id,
scopeLabel: existing.ownerScopeId,
});
if (input.entrypoint && input.files) {
if ((input.entrypoint !== undefined || input.files !== undefined) && input.files) {
const entrypoint = requiredEntrypoint(input.entrypoint, existing);
await this.redeploy(existing.id, {
entrypoint: input.entrypoint,
entrypoint,
files: input.files,
...(input.homeFiles ? { homeFiles: input.homeFiles } : {}),
...(input.env ? { env: input.env } : {}),
Expand All @@ -597,7 +609,6 @@ export function createDeployService(deps: DeployServiceDeps): DeployService {
return (await deps.deployStore.get(existing.id))!;
}

if (!input.entrypoint) throw new Error("publish requires an entrypoint");
const files = input.files ?? [];
const existing = input.name !== undefined ? await deps.deployStore.getByName(input.name) : null;
let d: Deployment;
Expand All @@ -609,18 +620,20 @@ export function createDeployService(deps: DeployServiceDeps): DeployService {
) {
throw new Error(`deployment name taken: ${input.name}`);
}
const entrypoint = requiredEntrypoint(input.entrypoint, existing);
d = await this.redeploy(existing.id, {
entrypoint: input.entrypoint,
entrypoint,
files,
...(input.homeFiles ? { homeFiles: input.homeFiles } : {}),
...(input.env ? { env: input.env } : {}),
});
isCreate = false;
} else {
const entrypoint = requiredEntrypoint(input.entrypoint, existing);
d = await this.deploy({
ownerScopeId,
createdBy,
entrypoint: input.entrypoint,
entrypoint,
files,
...(input.homeFiles ? { homeFiles: input.homeFiles } : {}),
...(input.name !== undefined ? { name: input.name } : {}),
Expand Down
38 changes: 29 additions & 9 deletions src/tools/primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import type {
} from "../connectors/background-exec-broker.ts";
import type { MonitorBroker, BackgroundWatchResult, BackgroundUnwatchResult } from "../monitors/monitor-broker.ts";
import type { DeployService, DeployFile } from "../deploy/deploy-service.ts";
import { publicUrlOf } from "../deploy/deploy-store.ts";
import { publicUrlOf, type Deployment } from "../deploy/deploy-store.ts";
import { carriesGitMetadata } from "../deploy/deploy-fs.ts";
import type { AclStore } from "../acl/acl-store.ts";
import type { AuditLog } from "../audit/audit-log.ts";
Expand Down Expand Up @@ -100,6 +100,11 @@ interface PublishResult {
dataDir?: string;
}

function deploymentEntrypoint(d: Deployment | null): string | undefined {
if (!d) return undefined;
return d.versions.find((v) => v.version === d.currentVersion)?.entrypoint || undefined;
}

export class NeedsApproval extends Error {
command: string;
approvalReason: string;
Expand Down Expand Up @@ -673,17 +678,28 @@ export function createToolContext(deps: ToolContextDeps): ToolContext {
if (!writableScopeId) throw new Error("publish needs a writable scope to own the app");
const owner: ScopeId = scopeId("personal", deps.createdBy);
const createdInScope: ScopeId = writableScopeId;
if (input.entrypoint && input.dir && hasParentPathSegment(input.dir)) {
let effectiveEntrypoint = input.entrypoint;
if (effectiveEntrypoint === undefined && input.rollbackTo === undefined) {
const shouldInheritForRename = input.renameFrom !== undefined && input.dir !== undefined;
const priorName =
input.renameFrom === undefined || shouldInheritForRename ? (input.renameFrom ?? input.name) : undefined;
const prior = priorName ? await deps.deploy.getDeployment(priorName) : null;
effectiveEntrypoint = deploymentEntrypoint(prior);
if (!effectiveEntrypoint && input.renameFrom === undefined) {
throw new Error('publish requires an entrypoint, e.g. "node server.js"');
}
}
if (effectiveEntrypoint && input.dir && hasParentPathSegment(input.dir)) {
throw new Error("publish directory must stay inside the workspace — no .. path segments");
}
const handle = await deps.provision();
const files: DeployFile[] = input.entrypoint
const files: DeployFile[] = effectiveEntrypoint
? filesUnder(await collectTree(deps.sandbox, handle, input.dir), input.dir)
: [];
if (input.entrypoint && files.length === 0) {
if (effectiveEntrypoint && files.length === 0) {
throw new Error(`publish: no files found under ${input.dir ?? "."} - nothing to deploy`);
}
const { authEnv } = input.entrypoint
const { authEnv } = effectiveEntrypoint
? await captureResidentAuth(deps.sandbox, handle, {
split: true,
...(deps.actingSlackUserId ? { actingSlackUserId: deps.actingSlackUserId } : {}),
Expand All @@ -706,7 +722,7 @@ export function createToolContext(deps: ToolContextDeps): ToolContext {
: { kind: "owner", grantees: [] };
const optOut = Array.isArray(input.share) && input.share.length === 0;
const desiredDefault = optOut ? [] : aud.grantees;
const doReconcile = input.entrypoint !== undefined && (optOut || !aud.incomplete);
const doReconcile = effectiveEntrypoint !== undefined && (optOut || !aud.incomplete);
const snapshotAt = Date.now();
const resolvedShare = input.share?.map((s) => {
const scope = s.scope === "org" ? orgScopeId : s.scope;
Expand Down Expand Up @@ -746,12 +762,12 @@ export function createToolContext(deps: ToolContextDeps): ToolContext {
d.createdInScope,
);
const audience: PublishAudienceDescriptor =
aud.incomplete && input.entrypoint !== undefined && input.share === undefined && aud.reason
aud.incomplete && effectiveEntrypoint !== undefined && input.share === undefined && aud.reason
? { ...base, note: aud.reason }
: base;
const urlBase = deps.publicWebUrl?.replace(/\/$/, "") ?? "";
const url = publicUrlOf(d.endpoint) ?? `${urlBase}/d/${ref}/`;
const dataDir = input.entrypoint ? deps.deploy.providerProfile?.dataDir : undefined;
const dataDir = effectiveEntrypoint ? deps.deploy.providerProfile?.dataDir : undefined;
return {
id: d.id,
...(d.name ? { name: d.name } : {}),
Expand Down Expand Up @@ -1046,7 +1062,10 @@ async function captureResidentAuth(
});
return { homeFiles: entries.map((e) => ({ path: e.path, data: e.data })), authEnv: {} };
} catch (e) {
if (e instanceof CapabilityUnsupportedError) return { homeFiles: [], authEnv: {} };
if (e instanceof CapabilityUnsupportedError) {
console.warn(`[publish] ${e.message}; skipping resident auth backup`);
return { homeFiles: [], authEnv: {} };
}
throw e;
}
}
Expand All @@ -1068,6 +1087,7 @@ async function collectTree(
return entries.filter((e) => !carriesGitMetadata(e.path)).map((e) => ({ path: e.path, data: e.data }));
} catch (e) {
if (!(e instanceof CapabilityUnsupportedError)) throw e;
console.warn(`[publish] ${e.message}; falling back to per-file reads`);
}
}
const out: Array<{ path: string; data: Uint8Array }> = [];
Expand Down
58 changes: 56 additions & 2 deletions test/publish-primitive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function svc() {
interface CtxOpts {
files?: Array<{ path: string; data: Uint8Array }>;
sandbox?: Sandbox;
provision?: () => Promise<SandboxHandle>;
ledger?: ToolLedger;
runId?: string;
rw?: boolean;
Expand All @@ -69,7 +70,7 @@ function ctx(deploy: DeployService, opts: CtxOpts = {}) {
const sandbox = opts.sandbox ?? fileSandbox(files);
return createToolContext({
sandbox,
provision: async () => ({}) as SandboxHandle,
provision: opts.provision ?? (async () => ({}) as SandboxHandle),
layers: opts.rw === false ? [] : [{ scopeId: scopeId("personal", "U1"), mountPath: "", mode: "rw" }],
commandPolicy: () => ({}) as never,
authorizeCommand: () => false,
Expand Down Expand Up @@ -122,9 +123,23 @@ test("publish falls back to per-file reads when the routed backend refuses backu
sandbox.backupComputer = async () => {
throw new CapabilityUnsupportedError("sprites", "backupComputer");
};
const warnings: unknown[][] = [];
const warn = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args);
};
const tc = ctx(s.deploy, { files, sandbox });
const r = await tc.publish({ dir: "dist", entrypoint: "node server.js", name: "sprite-app" });
const r = await (async () => {
try {
return await tc.publish({ dir: "dist", entrypoint: "node server.js", name: "sprite-app" });
} finally {
console.warn = warn;
}
})();
assert.equal(r.url, "/d/sprite-app/");
assert.deepEqual(warnings, [
["[publish] this computer's substrate (sprites) does not support backupComputer; falling back to per-file reads"],
]);
const d = (await s.deployStore.getByName("sprite-app"))!;
assert.deepEqual(
(await s.deployStore.treeOf(d.id, 1))?.map((f) => f.path).sort(),
Expand Down Expand Up @@ -165,6 +180,45 @@ test("publish by name updates in place: bumps the version, keeps the id and the
assert.equal(second.url, "/d/dash/", "stable link");
});

test("publish by name inherits the current entrypoint when redeploying without one", async () => {
const s = svc();
await ctx(s.deploy, { files: [{ path: "app/server.js", data: bytes("v1") }] }).publish({
dir: "app",
entrypoint: "node server.js",
name: "dash",
});
const second = await ctx(s.deploy, { files: [{ path: "app/server.js", data: bytes("v2") }] }).publish({
dir: "app",
name: "dash",
});

const d = (await s.deployStore.getByName("dash"))!;
assert.equal(second.version, 2);
assert.equal(d.versions[1]!.entrypoint, "node server.js");
assert.deepEqual(
(await s.deployStore.filesOf(d.id, 2))?.map((f) => ({ path: f.path, data: [...f.data] })),
[{ path: "server.js", data: [...bytes("v2")] }],
);
});

test("publish without an entrypoint and without a prior version fails before provisioning", async () => {
const s = svc();
let provisioned = false;
const tc = ctx(s.deploy, {
files: appFile("app/server.js"),
provision: async () => {
provisioned = true;
return {} as SandboxHandle;
},
});

await assert.rejects(() => tc.publish({ dir: "app", name: "new-app" }), {
message: 'publish requires an entrypoint, e.g. "node server.js"',
});
assert.equal(provisioned, false);
assert.equal((await s.deployStore.list()).length, 0);
});

test("publish: a different scope claiming a taken name is rejected", async () => {
const s = svc();
await ctx(s.deploy, { files: appFile() }).publish({ entrypoint: "x", name: "taken" });
Expand Down