From a6735ec2f7564e0380a97de6d9d721df0f27ba16 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 8 Sep 2026 22:24:21 +0800 Subject: [PATCH 1/5] feat(deploy): Template Entries decide what a template deployment opens and shares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Sealos Template may now declare `spec.entries.open` and `spec.entries.share`, full URLs rendered with the template's own `${{ }}` substitution. Open is what the Open control opens after the deployment; Share is what the Success Record's share strip copies, shows as a QR code and posts. Open falls back to the template's Sealos App CR `spec.data.url` (212 catalog templates carry one; Brain applied it and never read it), then to the automatic Default Open Port rule. Share falls back to the Open URL only — never to the App CR url, which may embed `#token=` or `/invite/`. Both entries are Deployment Access Endpoints with a declared URL (ADR 0079's contract): probed verbatim, query string kept, de-duplicated by full URL, admitted to the record only once verified. They are optional evidence — a declared link that fails its probe stays out of the record instead of failing a deployment the Ingress-derived entries already prove usable. When the Open URL matches an Ingress host and its longest-prefix path to a Service port, Brain presets `brain.io/default-open-port` on that Service unless the template already set it, so the AP Public Access Node's Open and the record's Open agree. The rules live in one pure module over rendered documents. Brain's own renderer runs them before dumping the YAML, so the annotation ships with the apply; for provider-applied templates the runner reads the same facts back (template source, Instance defaults, in-memory inputs, Ingresses, Services, App CR) once the Ingresses exist and patches the matched Service. An entry no Ingress of the deployment serves is dropped, which also keeps an unresolved expression from ever leaking into a URL. The Success Record snapshots its share address as `shareUrl`; the section shares that, falling back to the primary entry for records written before the field existed. Old records keep loading unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012nPEVVc7yiShA1SrfuTUqM --- .../deployment-task-success-section.tsx | 17 +- .../deploy/task/direct-timeline.test.ts | 101 +++++ .../features/deploy/task/direct-timeline.ts | 76 ++++ .../deploy/task/result-readiness.test.ts | 130 ++++++ .../features/deploy/task/result-readiness.ts | 8 +- apps/ui/src/features/deploy/task/runner.ts | 67 ++- .../task/template-provider-entries.test.ts | 265 ++++++++++++ .../deploy/task/template-provider-entries.ts | 245 +++++++++++ .../src/features/deploy/task/timeline.test.ts | 171 ++++++++ apps/ui/src/features/deploy/task/timeline.ts | 89 +++- .../features/deploy/template-entries.test.ts | 321 ++++++++++++++ .../src/features/deploy/template-entries.ts | 401 ++++++++++++++++++ .../features/deploy/template-renderer.test.ts | 203 +++++++++ .../src/features/deploy/template-renderer.ts | 52 +++ apps/ui/src/lib/brain-labels.ts | 7 + 15 files changed, 2138 insertions(+), 15 deletions(-) create mode 100644 apps/ui/src/features/deploy/task/template-provider-entries.test.ts create mode 100644 apps/ui/src/features/deploy/task/template-provider-entries.ts create mode 100644 apps/ui/src/features/deploy/template-entries.test.ts create mode 100644 apps/ui/src/features/deploy/template-entries.ts diff --git a/apps/ui/src/features/deploy/deployment-task-success-section.tsx b/apps/ui/src/features/deploy/deployment-task-success-section.tsx index 111699260..791442007 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-section.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-section.tsx @@ -7,10 +7,11 @@ import { Check, Copy, ExternalLink } from "lucide-react"; import { memo, useEffect, useRef } from "react"; import { prefersReducedMotion } from "@/features/deploy/deployment-task-success-confetti"; import { DeploymentTaskSuccessShareStrip } from "@/features/deploy/deployment-task-success-share"; -import type { - DeploymentTaskSuccessEntry, - DeploymentTaskSuccessSnapshot, - DeploymentTaskSuccessStep, +import { + type DeploymentTaskSuccessEntry, + type DeploymentTaskSuccessSnapshot, + type DeploymentTaskSuccessStep, + deploymentTaskSuccessShareUrl, } from "@/features/deploy/task/timeline"; import { useCopyFeedback } from "@/features/deploy/use-copy-feedback"; @@ -248,6 +249,10 @@ export const DeploymentTaskSuccessSection = memo( const guidance = success.guidance ?? []; const primaryEntry = entries.find(isOpenableEntry); const secondaryEntries = entries.filter((entry) => entry !== primaryEntry); + // What the strip shares: the record's snapshotted share address (a + // template's Share entry, else the Open URL at verification time), with + // the primary entry standing in for records written before it existed. + const shareUrl = deploymentTaskSuccessShareUrl(success); // A lone entry is headed by nothing, whatever heading its source gave it, // as the Public Access Node draws a lone Public Address (CONTEXT.md, // Deployment Task Success Record). @@ -347,14 +352,14 @@ export const DeploymentTaskSuccessSection = memo( )} - {primaryEntry == null ? null : ( + {primaryEntry == null || shareUrl == null ? null : ( )} diff --git a/apps/ui/src/features/deploy/task/direct-timeline.test.ts b/apps/ui/src/features/deploy/task/direct-timeline.test.ts index 0982cf6d8..715fcd0b1 100644 --- a/apps/ui/src/features/deploy/task/direct-timeline.test.ts +++ b/apps/ui/src/features/deploy/task/direct-timeline.test.ts @@ -5,6 +5,7 @@ import { applyApReadinessToResultCard, apResultResourceCardsFromArtifactSummary, resultResourceCardsFromArtifactSummary, + templateEntryAccessEndpointCards, } from "./direct-timeline"; test("direct deployment timeline creates no AP card before AP result evidence is known", () => { @@ -425,3 +426,103 @@ test("direct deployment timeline applies AP workload readiness to the AP card", } ); }); + +test("Template Entries become optional declared endpoint cards, de-duplicated by full URL", () => { + const host = "eagler-demo.example.sealos.run"; + const share = `https://${host}/?server=wss://${host}/`; + const ingressCards = resultResourceCardsFromArtifactSummary({ + resourceYamls: [ + `apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: eaglercraft-admin + namespace: ns-demo +spec: + tls: + - hosts: + - ${host} + rules: + - host: ${host} + http: + paths: + - path: /admin + pathType: Prefix + backend: + service: + name: eaglercraft + port: + number: 5201 +`, + ], + }); + assert.deepEqual( + ingressCards.map( + (card) => card.resultRef.kind === "AccessEndpoint" && card.resultRef.url + ), + [`https://${host}/admin`] + ); + + const cards = templateEntryAccessEndpointCards({ + entries: { open: `https://${host}/admin`, share }, + existingCards: ingressCards, + namespace: "ns-demo", + }); + // The Open URL is already observed through the Ingress; only Share is new. + assert.deepEqual(cards, [ + { + events: [], + id: "AccessEndpoint:ns-demo:template-entry:share", + required: false, + resultRef: { + id: "template-entry:share", + kind: "AccessEndpoint", + label: "Share address", + namespace: "ns-demo", + observer: { entry: "share", kind: "template-entry" }, + protocol: "https", + url: share, + }, + status: "creating", + title: "Share address", + }, + ]); +}); + +test("a Template Entry Open URL no card observes gets its own card, once even when Share repeats it", () => { + const url = "https://demo.example.sealos.run/app"; + const cards = templateEntryAccessEndpointCards({ + entries: { open: url, share: url }, + existingCards: [], + namespace: "ns-demo", + }); + assert.deepEqual( + cards.map((card) => [ + card.id, + card.resultRef.kind === "AccessEndpoint" && card.resultRef.label, + card.required, + ]), + [["AccessEndpoint:ns-demo:template-entry:open", "Web address", false]] + ); + assert.deepEqual( + templateEntryAccessEndpointCards({ + entries: { open: "wss://demo.example.sealos.run/" }, + existingCards: [], + namespace: "ns-demo", + }).map( + (card) => + card.resultRef.kind === "AccessEndpoint" && [ + card.resultRef.label, + card.resultRef.protocol, + ] + ), + [["WebSocket address", "wss"]] + ); + assert.deepEqual( + templateEntryAccessEndpointCards({ + entries: {}, + existingCards: [], + namespace: "ns-demo", + }), + [] + ); +}); diff --git a/apps/ui/src/features/deploy/task/direct-timeline.ts b/apps/ui/src/features/deploy/task/direct-timeline.ts index 43ffaa373..e6052fc24 100644 --- a/apps/ui/src/features/deploy/task/direct-timeline.ts +++ b/apps/ui/src/features/deploy/task/direct-timeline.ts @@ -5,7 +5,9 @@ import type { DeployTaskArtifactSummary } from "./schema"; import { type DeploymentResultResourceCard, type DeploymentResultResourceRef, + type DeploymentTemplateEntryUrls, deploymentResultResourceCardId, + type TemplateEntryRole, } from "./timeline"; const TEMPLATE_WORKLOAD_KIND_BY_NORMALIZED = new Map([ @@ -142,6 +144,80 @@ function ingressAccessEndpointCard(input: { }); } +const ACCESS_ENDPOINT_PROTOCOLS = new Set(["http", "https", "ws", "wss"]); + +function accessEndpointProtocol( + url: string +): "http" | "https" | "ws" | "wss" | null { + try { + const protocol = new URL(url).protocol.slice(0, -1); + return ACCESS_ENDPOINT_PROTOCOLS.has(protocol) + ? (protocol as "http" | "https" | "ws" | "wss") + : null; + } catch { + return null; + } +} + +function templateEntryLabel( + role: TemplateEntryRole, + protocol: "http" | "https" | "ws" | "wss" +): string { + if (role === "share") { + return "Share address"; + } + return protocol === "ws" || protocol === "wss" + ? "WebSocket address" + : "Web address"; +} + +/** + * Template Entries (ADR 0081) as Deployment Access Endpoint cards: one per + * declared URL, probed verbatim like an Agent-declared URL, query string + * included. A URL some other card already observes (an Ingress root, say) is + * not doubled — the record de-duplicates by full URL and the runner still + * knows which URL is the Open one. The cards are optional: a declared entry + * that fails its probe stays out of the record rather than failing a + * deployment whose Ingress-derived entries already gate usability. + */ +export function templateEntryAccessEndpointCards(input: { + entries: DeploymentTemplateEntryUrls; + existingCards: readonly DeploymentResultResourceCard[]; + namespace: string; +}): DeploymentResultResourceCard[] { + const seen = new Set( + input.existingCards.flatMap((card) => + card.resultRef.kind === "AccessEndpoint" && card.resultRef.url != null + ? [card.resultRef.url] + : [] + ) + ); + const roles: TemplateEntryRole[] = ["open", "share"]; + return roles.flatMap((role) => { + const url = input.entries[role]; + const protocol = url == null ? null : accessEndpointProtocol(url); + if (url == null || protocol == null || seen.has(url)) { + return []; + } + seen.add(url); + const label = templateEntryLabel(role, protocol); + return [ + resultCard( + { + id: `template-entry:${role}`, + kind: "AccessEndpoint", + label, + namespace: input.namespace, + observer: { entry: role, kind: "template-entry" }, + protocol, + url, + }, + { required: false } + ), + ]; + }); +} + function templateIngressIdentity( summary: DeployTaskArtifactSummary, doc: Record, diff --git a/apps/ui/src/features/deploy/task/result-readiness.test.ts b/apps/ui/src/features/deploy/task/result-readiness.test.ts index fd5a68747..326384f45 100644 --- a/apps/ui/src/features/deploy/task/result-readiness.test.ts +++ b/apps/ui/src/features/deploy/task/result-readiness.test.ts @@ -552,3 +552,133 @@ it("keeps the Ingress label when no AP of the task knows the host", async () => expect(observed.running).toBe(true); expect(observed.card.resultRef).toMatchObject({ label: "Web address" }); }); + +it("probes a Template Entry share address verbatim, query string included, and keeps its own label", async () => { + globalThis.fetch = probeFetch as unknown as typeof fetch; + fetcher.mockClear(); + const host = "eagler-demo.example.sealos.run"; + const share = `https://${host}/?server=wss://${host}/`; + + const observed = await observeDeploymentResultCardReadiness({ + allowedDomain: "example.sealos.run", + apCandidates: [{ name: "eaglercraft", namespace: "ns-demo" }], + card: { + events: [], + id: "AccessEndpoint:ns-demo:template-entry:share", + required: false, + resultRef: { + id: "template-entry:share", + kind: "AccessEndpoint", + label: "Share address", + namespace: "ns-demo", + observer: { entry: "share", kind: "template-entry" }, + protocol: "https", + url: share, + }, + status: "creating", + title: "Share address", + }, + kubeconfig: "kubeconfig", + }); + + expect(observed.status).toBe("running"); + expect(probeFetch).toHaveBeenCalledTimes(1); + expect(String(probeFetch.mock.calls[0]?.[0])).toBe(share); + // A Share entry is never renamed after a port; the AP view is not even read. + expect(fetcher).not.toHaveBeenCalled(); + expect(observed.card.resultRef).toMatchObject({ + label: "Share address", + url: share, + }); +}); + +it("names a Template Entry Open address after the App Listening Port it reaches, without root discovery", async () => { + globalThis.fetch = probeFetch as unknown as typeof fetch; + fetcher.mockResolvedValueOnce({ + status: { + network: { + appListeningPorts: [ + { displayName: "game", port: 5200 }, + { displayName: "admin", port: 5201 }, + ], + publicAddresses: [ + { + host: "eagler-demo.example.sealos.run", + id: "observed-1", + port: 5200, + status: "accessible", + type: "observed", + url: "wss://eagler-demo.example.sealos.run/", + }, + { + host: "eagler-demo.example.sealos.run", + id: "observed-2", + port: 5201, + status: "accessible", + type: "observed", + url: "https://eagler-demo.example.sealos.run/admin", + }, + ], + }, + }, + }); + + const observed = await observeDeploymentResultCardReadiness({ + allowedDomain: "example.sealos.run", + apCandidates: [{ name: "eaglercraft", namespace: "ns-demo" }], + card: { + events: [], + id: "AccessEndpoint:ns-demo:template-entry:open", + required: false, + resultRef: { + id: "template-entry:open", + kind: "AccessEndpoint", + label: "Web address", + namespace: "ns-demo", + observer: { entry: "open", kind: "template-entry" }, + protocol: "https", + url: "https://eagler-demo.example.sealos.run/admin", + }, + status: "creating", + title: "Web address", + }, + kubeconfig: "kubeconfig", + }); + + expect(observed.status).toBe("running"); + expect(observed.card.resultRef).toMatchObject({ + label: "admin · 5201", + url: "https://eagler-demo.example.sealos.run/admin", + }); +}); + +it("never verifies a declared Template Entry through a root it did not declare", async () => { + probeFetch.mockImplementationOnce( + async () => new Response(null, { status: 404 }) + ); + globalThis.fetch = probeFetch as unknown as typeof fetch; + + const observed = await observeDeploymentResultCardReadiness({ + allowedDomain: "example.sealos.run", + card: { + events: [], + id: "AccessEndpoint:ns-demo:template-entry:open", + required: false, + resultRef: { + id: "template-entry:open", + kind: "AccessEndpoint", + label: "Web address", + namespace: "ns-demo", + observer: { entry: "open", kind: "template-entry" }, + protocol: "https", + url: "https://eagler-demo.example.sealos.run/admin", + }, + status: "creating", + title: "Web address", + }, + kubeconfig: "kubeconfig", + }); + + expect(observed.status).toBe("unknown"); + expect(probeFetch).toHaveBeenCalledTimes(1); +}); diff --git a/apps/ui/src/features/deploy/task/result-readiness.ts b/apps/ui/src/features/deploy/task/result-readiness.ts index bd8eb63ed..7f78e8dea 100644 --- a/apps/ui/src/features/deploy/task/result-readiness.ts +++ b/apps/ui/src/features/deploy/task/result-readiness.ts @@ -485,9 +485,15 @@ async function accessEndpointReadiness( signal: input.signal, }); publicUrl = resolved.url; + // A Template Entry's Open URL is named like an Ingress host once verified: + // by the App Listening Port it reaches. A Share entry keeps its own label. + const namedByPort = + resultRef.observer.kind === "ingress" || + (resultRef.observer.kind === "template-entry" && + resultRef.observer.entry === "open"); if ( portLabel === undefined && - resultRef.observer.kind === "ingress" && + namedByPort && input.apCandidates != null && input.apCandidates.length > 0 ) { diff --git a/apps/ui/src/features/deploy/task/runner.ts b/apps/ui/src/features/deploy/task/runner.ts index 7f1b9aca5..427e339e6 100644 --- a/apps/ui/src/features/deploy/task/runner.ts +++ b/apps/ui/src/features/deploy/task/runner.ts @@ -87,7 +87,10 @@ import { } from "./billing-failure-judgment"; import { buildRuntimeContract } from "./build-runtime-contract"; import { resolveGithubTokenForDeploymentTask } from "./credential-binding"; -import { resultResourceCardsFromArtifactSummary } from "./direct-timeline"; +import { + resultResourceCardsFromArtifactSummary, + templateEntryAccessEndpointCards, +} from "./direct-timeline"; import { isDeployTaskAbortError } from "./engine/errors"; import type { DeployTaskHandle } from "./engine/handle"; import { @@ -182,6 +185,7 @@ import { } from "./sensitive-inputs"; import { getDeployTaskById, getDeployTaskTimelineSnapshot } from "./service"; import { resolveDeploymentSuccessOpenUrl } from "./success-open-url"; +import { templateProviderTemplateEntries } from "./template-provider-entries"; import { templateProviderPublicAccessCards } from "./template-provider-public-access"; import { appendCardEvent, @@ -191,6 +195,7 @@ import { attachDeploymentTaskSuccess, DEPLOYMENT_TASK_TERMINAL_FAILURE_EVENT_KEY, type DeploymentResultResourceCard, + type DeploymentTemplateEntryUrls, deploymentTaskSuccessFromTimeline, deploymentTimelineFailureStepId, deploymentTimelineResultReadinessReached, @@ -613,6 +618,8 @@ async function applyDeploymentArtifact(input: { }): Promise<{ artifactSummary: DeployTaskArtifactSummary; notes: string; + /** Template Entries Brain rendered itself (ADR 0081). */ + templateEntries?: DeploymentTemplateEntryUrls; templateProviderResources?: DeploymentTemplateInstanceArtifact["resources"]; }> { if (input.artifact.kind === "template-instance-pending") { @@ -707,6 +714,9 @@ async function applyDeploymentArtifact(input: { notes: `Deployed Sealos template ${applied.instanceName}.`, }), notes: `Deployed Sealos template ${applied.instanceName}.`, + ...(input.artifact.rendered.entries === undefined + ? {} + : { templateEntries: input.artifact.rendered.entries }), }; } @@ -2631,6 +2641,7 @@ async function completeTaskWithArtifact(input: { taskDeadlineAtMs, }); let templatePublicAccessCards: DeploymentResultResourceCard[] = []; + let templateEntries = applied.templateEntries; if (applied.templateProviderResources !== undefined) { const discoverySignal = deploymentOperationSignal({ deadlineAtMs: readinessDeadlineAtMs, @@ -2645,6 +2656,16 @@ async function completeTaskWithArtifact(input: { resources: applied.templateProviderResources, signal: discoverySignal, }); + // The provider rendered the documents, so the Template Entries are + // read back off the cluster once the Ingresses exist (ADR 0081). + templateEntries = await templateProviderTemplateEntries({ + ...templateProviderEntryContext(input.artifact), + kubeconfig: input.kubeconfig, + namespace: input.task.namespace, + resources: applied.templateProviderResources, + routingDomain: apUserDomain(input.kubeconfig), + signal: discoverySignal, + }); } catch (error) { throwIfDeploymentOperationAborted({ deadlineAtMs: readinessDeadlineAtMs, @@ -2657,10 +2678,20 @@ async function completeTaskWithArtifact(input: { } } - const resultCards = [ + const observedCards = [ ...resultResourceCardsFromArtifactSummary(persistedSummary), ...templatePublicAccessCards, ]; + const resultCards = [ + ...observedCards, + ...(templateEntries === undefined + ? [] + : templateEntryAccessEndpointCards({ + entries: templateEntries, + existingCards: observedCards, + namespace: input.task.namespace, + })), + ]; for (const card of resultCards) { await upsertResultTimelineCard({ card, @@ -2716,6 +2747,7 @@ async function completeTaskWithArtifact(input: { const success = deploymentTaskSuccessFromTimeline(timeline, { primaryEntryUrl, ...deploymentTaskSourceProduct(input.task.source), + templateEntries, }); return success == null ? timeline @@ -2733,6 +2765,37 @@ async function completeTaskWithArtifact(input: { }); } +/** + * What the provider-path entry read-back needs from the artifact: the + * template, the instance, and this run's args (a resumed `template-instance` + * artifact holds none — its input-bound entries then fail the Ingress-host + * gate and drop out, never inventing a value). + */ +function templateProviderEntryContext(artifact: DeploymentArtifact): { + args: Record; + instanceName: string; + templateName: string; +} { + switch (artifact.kind) { + case "template-instance-pending": + return { + args: artifact.args, + instanceName: artifact.instanceName, + templateName: artifact.templateName, + }; + case "template-instance": + return { + args: {}, + instanceName: artifact.instanceName, + templateName: artifact.templateName, + }; + default: + throw new Error( + "Template Entries are read back only for provider-applied templates." + ); + } +} + function appliedResultIdentities( task: DeployTaskRow, artifact: DeploymentArtifact diff --git a/apps/ui/src/features/deploy/task/template-provider-entries.test.ts b/apps/ui/src/features/deploy/task/template-provider-entries.test.ts new file mode 100644 index 000000000..af19bb4b2 --- /dev/null +++ b/apps/ui/src/features/deploy/task/template-provider-entries.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { createRequire } from "node:module"; + +// The provider source and every cluster read go through globalThis.fetch, +// stubbed here instead of mock.module("@workspace/api/fetch"): a module mock +// outlives its file in one bun run and would poison later consumers of the +// fetch module (see runner.template-preserve.test.ts). + +const requireModule = createRequire(import.meta.url); + +mock.module("server-only", () => ({})); + +const { templateProviderTemplateEntries } = requireModule( + "./template-provider-entries" +) as typeof import("./template-provider-entries"); + +const TEMPLATE_EXPRESSION_START = String.fromCharCode(36, 123, 123); +const APP_HOST_EXPRESSION = `${TEMPLATE_EXPRESSION_START} defaults.app_host }}.${TEMPLATE_EXPRESSION_START} SEALOS_CLOUD_DOMAIN }}`; +const HOST = "eagler-demo.example.sealos.run"; +const TEMPLATE_YAML = { + apiVersion: "app.sealos.io/v1", + kind: "Template", + metadata: { name: "eaglercraft-server" }, + spec: { + entries: { + open: `https://${APP_HOST_EXPRESSION}/admin`, + share: `https://${APP_HOST_EXPRESSION}/?server=wss://${APP_HOST_EXPRESSION}/&name=${TEMPLATE_EXPRESSION_START} inputs.server_name }}`, + }, + title: "EaglerCraft Server", + }, +}; + +const originalFetch = globalThis.fetch; +const originalProviderUrl = process.env.TEMPLATE_PROVIDER_URL; + +interface RecordedPatch { + body: unknown; + query: Record; +} + +const patches: RecordedPatch[] = []; +let providerSource: () => Promise = () => + Promise.resolve(new Response(null, { status: 500 })); + +function installCluster(objects: Record>) { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/api/getTemplateSource")) { + return providerSource(); + } + const query = Object.fromEntries(url.searchParams.entries()); + if (url.pathname.endsWith("/api/k8s/v1alpha1/patch")) { + patches.push({ + body: JSON.parse(String(init?.body ?? "null")), + query, + }); + return Promise.resolve(Response.json({})); + } + if (url.pathname.endsWith("/api/k8s/v1alpha1/get")) { + const object = objects[query.kind ?? ""]?.[query.name ?? ""]; + return Promise.resolve( + object == null + ? new Response("not found", { status: 404 }) + : Response.json(object) + ); + } + return Promise.resolve(new Response("unexpected", { status: 500 })); + }) as unknown as typeof fetch; +} + +function clusterObjects(input: { preset?: string } = {}) { + return { + apps: { + eaglercraft: { + apiVersion: "app.sealos.io/v1", + kind: "App", + metadata: { name: "eaglercraft" }, + spec: { data: { url: `https://${HOST}/admin` } }, + }, + }, + ingresses: { + eaglercraft: { + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { name: "eaglercraft" }, + spec: { + rules: [ + { + host: HOST, + http: { + paths: [ + { + backend: { + service: { name: "eaglercraft", port: { number: 5200 } }, + }, + path: "/", + pathType: "Prefix", + }, + ], + }, + }, + ], + }, + }, + "eaglercraft-admin": { + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { name: "eaglercraft-admin" }, + spec: { + rules: [ + { + host: HOST, + http: { + paths: [ + { + backend: { + service: { name: "eaglercraft", port: { number: 5201 } }, + }, + path: "/admin", + pathType: "Prefix", + }, + ], + }, + }, + ], + }, + }, + }, + instances: { + eaglercraft: { + apiVersion: "app.sealos.io/v1", + kind: "Instance", + metadata: { name: "eaglercraft" }, + spec: { + defaults: { + app_host: { type: "string", value: "eagler-demo" }, + app_name: { type: "string", value: "eaglercraft" }, + }, + }, + }, + }, + services: { + eaglercraft: { + apiVersion: "v1", + kind: "Service", + metadata: { + name: "eaglercraft", + ...(input.preset === undefined + ? {} + : { annotations: { "brain.io/default-open-port": input.preset } }), + }, + spec: { + ports: [ + { name: "game", port: 5200 }, + { name: "admin", port: 5201 }, + ], + }, + }, + }, + } as Record>; +} + +const RESOURCES = [ + { name: "eaglercraft", resourceType: "deployment", uid: "1" }, + { name: "eaglercraft", resourceType: "service", uid: "2" }, + { name: "eaglercraft", resourceType: "ingress", uid: "3" }, + { name: "eaglercraft-admin", resourceType: "ingress", uid: "4" }, +]; + +function readBack(args: Record) { + return templateProviderTemplateEntries({ + args, + instanceName: "eaglercraft", + kubeconfig: "kubeconfig", + namespace: "ns-demo", + resources: RESOURCES, + routingDomain: "example.sealos.run", + templateName: "eaglercraft-server", + }); +} + +describe("templateProviderTemplateEntries", () => { + beforeEach(() => { + process.env.TEMPLATE_PROVIDER_URL = "https://provider.test"; + patches.length = 0; + providerSource = () => + Promise.resolve( + Response.json({ + code: 200, + data: { + appYaml: "kind: Deployment", + source: { + defaults: {}, + inputs: [{ default: "Lobby", key: "server_name" }], + }, + templateYaml: TEMPLATE_YAML, + }, + }) + ); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (originalProviderUrl === undefined) { + Reflect.deleteProperty(process.env, "TEMPLATE_PROVIDER_URL"); + } else { + process.env.TEMPLATE_PROVIDER_URL = originalProviderUrl; + } + }); + + it("reads resolved defaults off the Instance, renders the entries, and presets the Default Open Port", async () => { + installCluster(clusterObjects()); + + const entries = await readBack({ server_name: "My Server" }); + + expect(entries).toEqual({ + open: `https://${HOST}/admin`, + share: `https://${HOST}/?server=wss://${HOST}/&name=My Server`, + }); + expect(patches).toEqual([ + { + body: { + metadata: { + annotations: { "brain.io/default-open-port": "5201" }, + }, + }, + query: { + kind: "services", + name: "eaglercraft", + namespace: "ns-demo", + type: "merge", + }, + }, + ]); + }); + + it("leaves a Service the template already preset alone", async () => { + installCluster(clusterObjects({ preset: "5200" })); + + const entries = await readBack({}); + + expect(entries?.open).toBe(`https://${HOST}/admin`); + expect(patches).toEqual([]); + }); + + it("falls back to the App CR url for Open only when the Instance defaults are unresolved", async () => { + const objects = clusterObjects(); + objects.instances = {}; + installCluster(objects); + + const entries = await readBack({}); + + // `${{ defaults.app_host }}` rendered empty: neither entry names an + // Ingress host, so Open comes from the App CR and Share is absent. + expect(entries).toEqual({ open: `https://${HOST}/admin` }); + }); + + it("degrades to no entries when the provider source cannot be read", async () => { + providerSource = () => Promise.reject(new Error("provider down")); + installCluster(clusterObjects()); + + await expect(readBack({})).resolves.toBeUndefined(); + expect(patches).toEqual([]); + }); +}); diff --git a/apps/ui/src/features/deploy/task/template-provider-entries.ts b/apps/ui/src/features/deploy/task/template-provider-entries.ts new file mode 100644 index 000000000..449eb55fa --- /dev/null +++ b/apps/ui/src/features/deploy/task/template-provider-entries.ts @@ -0,0 +1,245 @@ +import "server-only"; + +import { API_ROUTES } from "@workspace/api/constants"; +import { fetcher } from "@workspace/api/fetch"; +import { ApiUrl } from "@workspace/api/utils"; +import { + resolveTemplateEntryUrls, + type TemplateEntryUrls, + templateAppUrlFromDocs, + templateDeclaredEntries, + templateEntryOpenPort, + templateIngressHostsFromDocs, + templateInstanceDefaults, +} from "@/features/deploy/template-entries"; +import { + getTemplateSource, + type TemplateDeploymentResourceSummary, +} from "@/features/deploy/template-provider-core"; +import { renderTemplateEntryExpressions } from "@/features/deploy/template-renderer"; +import { BRAIN_DEFAULT_OPEN_PORT_ANNOTATION } from "@/lib/brain-labels"; +import { kubeconfigBearerHeader } from "@/lib/kubeconfig-header"; + +const INGRESS_RESOURCE_TYPES = new Set(["ingress", "ingresses"]); +const SERVICE_RESOURCE_TYPES = new Set(["service", "services"]); +const APP_RESOURCE_TYPES = new Set(["app", "apps"]); + +function objectValue(value: unknown): Record | null { + return value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function resourceNames( + resources: readonly TemplateDeploymentResourceSummary[], + types: ReadonlySet +): string[] { + const names = new Set(); + for (const resource of resources) { + const name = resource.name.trim(); + if (name !== "" && types.has(resource.resourceType.trim().toLowerCase())) { + names.add(name); + } + } + return [...names]; +} + +async function getObject(input: { + kind: string; + kubeconfig: string; + name: string; + namespace: string; + signal?: AbortSignal; +}): Promise | null> { + try { + return objectValue( + await fetcher({ + base: ApiUrl(), + header: { Authorization: kubeconfigBearerHeader(input.kubeconfig) }, + method: "GET", + path: API_ROUTES.k8s.get, + query: { + kind: input.kind, + name: input.name, + namespace: input.namespace, + }, + signal: input.signal, + }) + ); + } catch (error) { + if (input.signal?.aborted) { + throw error; + } + return null; + } +} + +async function getObjects(input: { + kind: string; + kubeconfig: string; + names: readonly string[]; + namespace: string; + signal?: AbortSignal; +}): Promise[]> { + const objects = await Promise.all( + input.names.map((name) => getObject({ ...input, name })) + ); + return objects.flatMap((object) => (object == null ? [] : [object])); +} + +async function presetDefaultOpenPort(input: { + kubeconfig: string; + namespace: string; + port: number; + service: Record; + serviceName: string; + signal?: AbortSignal; +}): Promise { + const annotations = objectValue( + objectValue(input.service.metadata)?.annotations + ); + const preset = annotations?.[BRAIN_DEFAULT_OPEN_PORT_ANNOTATION]; + if (typeof preset === "string" && preset.trim() !== "") { + return; + } + await fetcher({ + base: ApiUrl(), + body: { + metadata: { + annotations: { + [BRAIN_DEFAULT_OPEN_PORT_ANNOTATION]: String(input.port), + }, + }, + }, + header: { Authorization: kubeconfigBearerHeader(input.kubeconfig) }, + method: "PATCH", + path: API_ROUTES.k8s.patch, + query: { + kind: "services", + name: input.serviceName, + namespace: input.namespace, + type: "merge", + }, + signal: input.signal, + }); +} + +/** Provider input declarations, as `key -> default` for absent args. */ +function providerInputDefaults(source: unknown): Record { + const inputs = objectValue(source)?.inputs; + const out: Record = {}; + for (const item of Array.isArray(inputs) ? inputs : []) { + const input = objectValue(item); + const key = typeof input?.key === "string" ? input.key.trim() : ""; + if (key !== "" && typeof input?.default === "string") { + out[key] = input.default; + } + } + return out; +} + +/** + * Template Entries (ADR 0081) for an instance the template provider applied. + * Brain did not render these documents, so it reads them back: the declared + * entries off the template source, the resolved defaults off the Instance + * CR, the inputs from this run's memory, and the Ingresses, Services, and + * App CR from the cluster. The same pure rules as Brain's own renderer then + * decide the URLs and preset the Default Open Port on the matched Service. + * Entries are advisory: any failure here degrades to no declared entry and + * the automatic rule, never to a failed deployment. + */ +export async function templateProviderTemplateEntries(input: { + args: Record; + instanceName: string; + kubeconfig: string; + namespace: string; + resources: readonly TemplateDeploymentResourceSummary[]; + routingDomain?: string; + signal?: AbortSignal; + templateName: string; +}): Promise { + try { + return await resolveProviderEntries(input); + } catch (error) { + if (input.signal?.aborted) { + throw error; + } + console.warn( + `[deploy-task] Could not resolve template entries for instance ${input.instanceName}.`, + error + ); + return undefined; + } +} + +async function resolveProviderEntries( + input: Parameters[0] +): Promise { + const read = { + kubeconfig: input.kubeconfig, + namespace: input.namespace, + signal: input.signal, + }; + const source = await getTemplateSource({ + encodedKubeconfig: input.kubeconfig, + templateName: input.templateName, + }); + const declared = templateDeclaredEntries(source.templateYaml); + const appNames = new Set([ + ...resourceNames(input.resources, APP_RESOURCE_TYPES), + input.instanceName, + ]); + const [ingresses, services, apps, instance] = await Promise.all([ + getObjects({ + ...read, + kind: "ingresses", + names: resourceNames(input.resources, INGRESS_RESOURCE_TYPES), + }), + getObjects({ + ...read, + kind: "services", + names: resourceNames(input.resources, SERVICE_RESOURCE_TYPES), + }), + getObjects({ ...read, kind: "apps", names: [...appNames] }), + declared.open === undefined && declared.share === undefined + ? Promise.resolve(null) + : getObject({ ...read, kind: "instances", name: input.instanceName }), + ]); + const rendered = + declared.open === undefined && declared.share === undefined + ? {} + : renderTemplateEntryExpressions({ + declared, + defaults: { + ...templateInstanceDefaults(instance), + app_name: input.instanceName, + }, + inputs: { ...providerInputDefaults(source.source), ...input.args }, + namespace: input.namespace, + routingDomain: input.routingDomain, + }); + const docs = [...ingresses, ...services, ...apps]; + const entries = resolveTemplateEntryUrls({ + appUrl: templateAppUrlFromDocs(apps), + declared: rendered, + hosts: templateIngressHostsFromDocs(ingresses), + }); + if (entries.open !== undefined) { + const target = templateEntryOpenPort({ docs, openUrl: entries.open }); + const service = services.find( + (candidate) => + objectValue(candidate.metadata)?.name === target?.serviceName + ); + if (target !== undefined && service !== undefined) { + await presetDefaultOpenPort({ + ...read, + port: target.port, + service, + serviceName: target.serviceName, + }); + } + } + return entries.open === undefined && entries.share === undefined + ? undefined + : entries; +} diff --git a/apps/ui/src/features/deploy/task/timeline.test.ts b/apps/ui/src/features/deploy/task/timeline.test.ts index d4504cc9e..22b15309f 100644 --- a/apps/ui/src/features/deploy/task/timeline.test.ts +++ b/apps/ui/src/features/deploy/task/timeline.test.ts @@ -15,6 +15,7 @@ import { declareTimelineSteps, deploymentTaskSuccessFromResultReadiness, deploymentTaskSuccessFromTimeline, + deploymentTaskSuccessShareUrl, deploymentTaskSuccessSignature, deploymentTimelineFailureStepId, deploymentTimelineResultReadinessReached, @@ -850,6 +851,7 @@ test("a verified template Ingress becomes the Public domain success entry", () = }, ], productName: "AFFiNE", + shareUrl: "https://affine.example.sealos.run", verification: { passed: 3, total: 3 }, } ); @@ -899,6 +901,7 @@ test("a verified generic access endpoint becomes a v2 success entry", () => { }, ], productName: "nginx", + shareUrl: "https://nginx.example.sealos.run", verification: { passed: 3, total: 3 }, } ); @@ -1210,3 +1213,171 @@ test("product categories are part of a record's identity", () => { deploymentTaskSuccessSignature({ ...base, productCategories: ["game"] }) ); }); + +const EAGLER_HOST = "eagler-demo.example.sealos.run"; +const EAGLER_OPEN = `https://${EAGLER_HOST}/admin`; +const EAGLER_SHARE = `https://${EAGLER_HOST}/?server=wss://${EAGLER_HOST}/`; + +function endpointCard(input: { + id: string; + label: string; + observer: DeploymentResultResourceCard["resultRef"] extends infer R + ? R extends { kind: "AccessEndpoint"; observer: infer O } + ? O + : never + : never; + protocol: "https" | "wss"; + required: boolean; + status: "creating" | "running"; + url: string; +}): DeploymentResultResourceCard { + return { + events: [], + id: `AccessEndpoint:default:${input.id}`, + required: input.required, + resultRef: { + id: input.id, + kind: "AccessEndpoint", + label: input.label, + namespace: "default", + observer: input.observer, + protocol: input.protocol, + url: input.url, + }, + status: input.status, + title: input.label, + }; +} + +function eaglercraftTimeline(shareStatus: "creating" | "running") { + let timeline = timelineFrame({ + "AP:default:eaglercraft": "running", + "PublicAccess:default:eaglercraft:lobby": "running", + }); + const cards = [ + endpointCard({ + id: "ingress:eaglercraft:wss:root", + label: "game · 5200", + observer: { kind: "ingress", name: "eaglercraft" }, + protocol: "wss", + required: true, + status: "running", + url: `wss://${EAGLER_HOST}/`, + }), + endpointCard({ + id: "ingress:eaglercraft-admin:https:admin", + label: "admin · 5201", + observer: { kind: "ingress", name: "eaglercraft-admin" }, + protocol: "https", + required: true, + status: "running", + url: EAGLER_OPEN, + }), + endpointCard({ + id: "template-entry:share", + label: "Share address", + observer: { entry: "share", kind: "template-entry" }, + protocol: "https", + required: false, + status: shareStatus, + url: EAGLER_SHARE, + }), + ]; + for (const card of cards) { + timeline = upsertResultResourceCard(timeline, { + card, + stepId: "create-resources", + updatedAt: NOW, + }); + } + return timeline; +} + +test("a verified Template Entry puts the Open URL first and snapshots the share address verbatim", () => { + const success = deploymentTaskSuccessFromTimeline( + eaglercraftTimeline("running"), + { + // The automatic rule would open the game root; the template's Open wins. + primaryEntryUrl: `https://${EAGLER_HOST}/`, + productName: "EaglerCraft Server", + templateEntries: { open: EAGLER_OPEN, share: EAGLER_SHARE }, + } + ); + assert.deepEqual( + success?.entries?.map((entry) => entry.url), + [EAGLER_OPEN, `wss://${EAGLER_HOST}/`, EAGLER_SHARE] + ); + assert.equal(success?.shareUrl, EAGLER_SHARE); + // Optional entry evidence never inflates the verification count. + assert.deepEqual(success?.verification, { passed: 4, total: 4 }); +}); + +test("an unverified Template Entry stays out of the record and the share falls back to the Open URL", () => { + const success = deploymentTaskSuccessFromTimeline( + eaglercraftTimeline("creating"), + { + primaryEntryUrl: `https://${EAGLER_HOST}/`, + productName: "EaglerCraft Server", + templateEntries: { open: EAGLER_OPEN, share: EAGLER_SHARE }, + } + ); + assert.deepEqual( + success?.entries?.map((entry) => entry.url), + [EAGLER_OPEN, `wss://${EAGLER_HOST}/`] + ); + assert.equal(success?.shareUrl, EAGLER_OPEN); + + // A declared Open URL the probe never confirmed leaves the automatic rule in charge. + const unverifiedOpen = deploymentTaskSuccessFromTimeline( + eaglercraftTimeline("running"), + { + primaryEntryUrl: `wss://${EAGLER_HOST}/`, + productName: null, + templateEntries: { open: `https://${EAGLER_HOST}/elsewhere` }, + } + ); + assert.equal(unverifiedOpen?.entries?.[0]?.url, `wss://${EAGLER_HOST}/`); + assert.equal(unverifiedOpen?.shareUrl, EAGLER_OPEN); +}); + +test("the share address is a snapshot the sanitizer keeps for HTTP(S) only, and old records share their primary entry", () => { + const fallback = { revision: 2, verifiedAt: NOW }; + const kept = sanitizeDeploymentTaskSuccess( + { + entries: [{ protocol: "https", url: EAGLER_OPEN }], + shareUrl: ` ${EAGLER_SHARE} `, + }, + fallback + ); + assert.equal(kept?.shareUrl, EAGLER_SHARE); + const socket = sanitizeDeploymentTaskSuccess( + { shareUrl: `wss://${EAGLER_HOST}/` }, + fallback + ); + assert.equal(socket?.shareUrl, undefined); + + const legacy = sanitizeDeploymentTaskSuccess( + { + contractVersion: 2, + entries: [ + { protocol: "wss", url: `wss://${EAGLER_HOST}/` }, + { protocol: "https", url: EAGLER_OPEN }, + ], + }, + fallback + ); + assert.equal(legacy?.shareUrl, undefined); + assert.equal( + deploymentTaskSuccessShareUrl(legacy ?? { entries: [] }), + EAGLER_OPEN + ); + assert.equal(deploymentTaskSuccessShareUrl({ entries: [] }), undefined); + + assert.notEqual( + deploymentTaskSuccessSignature({ ...(kept as NonNullable) }), + deploymentTaskSuccessSignature({ + ...(kept as NonNullable), + shareUrl: EAGLER_OPEN, + }) + ); +}); diff --git a/apps/ui/src/features/deploy/task/timeline.ts b/apps/ui/src/features/deploy/task/timeline.ts index 5c79ab7d2..2bd5f2955 100644 --- a/apps/ui/src/features/deploy/task/timeline.ts +++ b/apps/ui/src/features/deploy/task/timeline.ts @@ -26,7 +26,17 @@ export type DeploymentAccessEndpointProtocol = "http" | "https" | "ws" | "wss"; export type DeploymentAccessEndpointObserver = | { addressId: string; apName: string; kind: "ap-public-address" } | { kind: "declared" } - | { kind: "ingress"; name: string }; + | { kind: "ingress"; name: string } + /** A Template Entry (ADR 0081): the template's declared Open or Share URL. */ + | { entry: TemplateEntryRole; kind: "template-entry" }; + +export type TemplateEntryRole = "open" | "share"; + +/** The Open and Share URLs a template deployment declared (ADR 0081). */ +export interface DeploymentTemplateEntryUrls { + open?: string; + share?: string; +} export type DeploymentTimelineEventSeverity = | "info" @@ -177,6 +187,13 @@ export interface DeploymentTaskSuccessSnapshot { * snapshot can never replay the confetti. */ revision: number; + /** + * The HTTP(S) address the share strip shares (ADR 0081): the template's + * verified Share entry, else the Open URL as it stood when the record was + * written. Absent on records written before the field existed, which + * share their primary entry. + */ + shareUrl?: string; verification?: DeploymentTaskSuccessVerification; verifiedAt: string; } @@ -733,6 +750,16 @@ function accessProtocol( } } +/** A share address is opened in a browser, so only HTTP(S) qualifies. */ +function successShareUrl(value: unknown): string | undefined { + const url = successUrl(value); + if (url == null) { + return undefined; + } + const protocol = accessProtocol(url); + return protocol === "http" || protocol === "https" ? url : undefined; +} + function successCount(value: unknown, max: number): number | undefined { return Number.isSafeInteger(value) && (value as number) >= 0 && @@ -853,6 +880,7 @@ export function sanitizeDeploymentTaskSuccess( ); const entries = successEntries(candidate.entries, contractVersion); const guidance = successGuidance(candidate.guidance); + const shareUrl = successShareUrl(candidate.shareUrl); const verification = successVerification(candidate.verification); const revision = successCount(candidate.revision, Number.MAX_SAFE_INTEGER); return { @@ -865,6 +893,7 @@ export function sanitizeDeploymentTaskSuccess( ...(productId == null ? {} : { productId }), ...(productName == null ? {} : { productName }), revision: revision ?? fallback.revision, + ...(shareUrl == null ? {} : { shareUrl }), verifiedAt: isoTimestamp(candidate.verifiedAt) ?? fallback.verifiedAt, ...(verification == null ? {} : { verification }), }; @@ -892,6 +921,7 @@ export function deploymentTaskSuccessSignature( productCategories: success.productCategories ?? [], productId: success.productId ?? "", productName: success.productName ?? "", + shareUrl: success.shareUrl ?? "", verification: success.verification ? [success.verification.passed, success.verification.total] : null, @@ -987,20 +1017,53 @@ export function prioritizeSuccessEntries( return [primary, ...entries.filter((_, position) => position !== index)]; } +/** A Template Entry card is optional evidence: verified, it enters the record. */ +function isTemplateEntryCard(card: DeploymentResultResourceCard): boolean { + return ( + card.resultRef.kind === "AccessEndpoint" && + card.resultRef.observer.kind === "template-entry" + ); +} + +/** + * The address the share strip shares: the record's own snapshot, else — for + * records written before the field existed — the primary HTTP(S) entry. + */ +export function deploymentTaskSuccessShareUrl( + success: Pick +): string | undefined { + return ( + success.shareUrl ?? + success.entries?.find((entry) => isHttpEntryUrl(entry.url))?.url + ); +} + export function deploymentTaskSuccessFromTimeline( timeline: DeploymentTaskTimelineSnapshot, input: DeploymentTaskSuccessProduct & { /** The Default Open Port's best Public Address, when the task has one. */ primaryEntryUrl?: string | null; + /** + * The template's declared entries (ADR 0081). A verified Open entry wins + * over `primaryEntryUrl`; a verified Share entry becomes the record's + * share address, else the Open URL is shared. An unverified entry is + * ignored: the record carries only addresses the probe confirmed. + */ + templateEntries?: DeploymentTemplateEntryUrls | null; } ): DeploymentTaskSuccessAttachment | null { if (!deploymentTimelineResultReadinessReached(timeline)) { return null; } - const runningCards = timeline.steps - .flatMap((step) => step.resultCards ?? []) - .filter((card) => card.required && card.status === "running"); - const endpointEntries = runningCards.flatMap((card) => { + const cards = timeline.steps.flatMap((step) => step.resultCards ?? []); + const runningCards = cards.filter( + (card) => card.required && card.status === "running" + ); + const verifiedEndpointCards = cards.filter( + (card) => + card.status === "running" && (card.required || isTemplateEntryCard(card)) + ); + const endpointEntries = verifiedEndpointCards.flatMap((card) => { switch (card.resultRef.kind) { case "AccessEndpoint": return card.resultRef.url == null @@ -1018,6 +1081,12 @@ export function deploymentTaskSuccessFromTimeline( return []; } }); + const verifiedUrls = new Set(endpointEntries.map((entry) => entry.url)); + const declaredOpen = input.templateEntries?.open; + const openUrl = + declaredOpen != null && verifiedUrls.has(declaredOpen) + ? declaredOpen + : input.primaryEntryUrl; const uniqueEntries = prioritizeSuccessEntries( endpointEntries.filter( (entry, index) => @@ -1025,8 +1094,15 @@ export function deploymentTaskSuccessFromTimeline( (candidate) => candidate.url === entry.url ) === index ), - input.primaryEntryUrl + openUrl ); + const declaredShare = input.templateEntries?.share; + const shareUrl = + declaredShare != null && + verifiedUrls.has(declaredShare) && + isHttpEntryUrl(declaredShare) + ? declaredShare + : deploymentTaskSuccessShareUrl({ entries: uniqueEntries }); const success = deploymentTaskSuccessFromResultReadiness({ productCategories: input.productCategories, productId: input.productId, @@ -1040,6 +1116,7 @@ export function deploymentTaskSuccessFromTimeline( ...(uniqueEntries.length === 0 ? { headline: "Deployment completed" } : { entries: uniqueEntries }), + ...(shareUrl == null ? {} : { shareUrl }), }; } diff --git a/apps/ui/src/features/deploy/template-entries.test.ts b/apps/ui/src/features/deploy/template-entries.test.ts new file mode 100644 index 000000000..4a2acba48 --- /dev/null +++ b/apps/ui/src/features/deploy/template-entries.test.ts @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + applyTemplateEntries, + resolveTemplateEntryUrls, + stampTemplateEntryOpenPort, + templateAppUrlFromDocs, + templateDeclaredEntries, + templateEntryOpenPort, + templateEntryUrl, + templateIngressHostsFromDocs, + templateInstanceDefaults, +} from "./template-entries"; + +const TEMPLATE_EXPRESSION_START = String.fromCharCode(36, 123, 123); +const APP_HOST_EXPRESSION = `${TEMPLATE_EXPRESSION_START} defaults.app_host }}`; +const HOST = "eagler-demo.example.sealos.run"; +const OPEN = `https://${HOST}/admin`; +const SHARE = `https://${HOST}/?server=wss://${HOST}/`; + +function service(annotations?: Record) { + return { + apiVersion: "v1", + kind: "Service", + metadata: { + name: "eaglercraft", + ...(annotations === undefined ? {} : { annotations }), + }, + spec: { + ports: [ + { name: "game", port: 5200, targetPort: 5200 }, + { name: "admin", port: 5201, targetPort: 5201 }, + ], + }, + }; +} + +function ingress( + name: string, + paths: { path: string; port: number | string }[] +) { + return { + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { name }, + spec: { + rules: [ + { + host: HOST, + http: { + paths: paths.map((entry) => ({ + backend: { + service: { + name: "eaglercraft", + port: + typeof entry.port === "number" + ? { number: entry.port } + : { name: entry.port }, + }, + }, + path: entry.path, + pathType: "Prefix", + })), + }, + }, + ], + }, + }; +} + +const APP_CR = { + apiVersion: "app.sealos.io/v1", + kind: "App", + metadata: { name: "eaglercraft" }, + spec: { data: { url: OPEN }, type: "link" }, +}; + +function eaglercraftDocs() { + return [ + service(), + ingress("eaglercraft", [{ path: "/", port: 5200 }]), + ingress("eaglercraft-admin", [ + { path: "/api", port: 5201 }, + { path: "/admin.css", port: 5201 }, + { path: "/admin", port: 5201 }, + ]), + APP_CR, + ]; +} + +test("templateDeclaredEntries reads spec.entries strings and ignores the rest", () => { + assert.deepEqual( + templateDeclaredEntries({ + spec: { entries: { open: ` ${OPEN} `, share: 42, other: "x" } }, + }), + { open: OPEN } + ); + assert.deepEqual(templateDeclaredEntries({ spec: {} }), {}); + assert.deepEqual(templateDeclaredEntries(null), {}); +}); + +test("templateEntryUrl keeps a query string verbatim and rejects secrets and fragments", () => { + assert.equal(templateEntryUrl(SHARE), SHARE); + assert.equal(templateEntryUrl(`wss://${HOST}/`), `wss://${HOST}/`); + assert.equal(templateEntryUrl(`https://${HOST}/#token=abc`), undefined); + assert.equal(templateEntryUrl(`https://user:pw@${HOST}/`), undefined); + assert.equal(templateEntryUrl("ftp://example.com/"), undefined); + assert.equal(templateEntryUrl("/admin"), undefined); + assert.equal(templateEntryUrl(""), undefined); +}); + +test("templateAppUrlFromDocs reads the Sealos App CR url", () => { + assert.equal(templateAppUrlFromDocs(eaglercraftDocs()), OPEN); + assert.equal(templateAppUrlFromDocs([service()]), undefined); + assert.equal( + templateAppUrlFromDocs([{ ...APP_CR, apiVersion: "other/v1" }]), + undefined + ); +}); + +test("templateIngressHostsFromDocs lists every Ingress rule host", () => { + assert.deepEqual( + [...templateIngressHostsFromDocs(eaglercraftDocs())], + [HOST] + ); +}); + +test("Open falls back to the App CR url; Share never does", () => { + const hosts = new Set([HOST]); + assert.deepEqual( + resolveTemplateEntryUrls({ appUrl: OPEN, declared: {}, hosts }), + { open: OPEN } + ); + assert.deepEqual( + resolveTemplateEntryUrls({ + appUrl: `https://${HOST}/invite/secret-code`, + declared: {}, + hosts, + }), + { open: `https://${HOST}/invite/secret-code` } + ); + assert.deepEqual( + resolveTemplateEntryUrls({ + appUrl: `https://${HOST}/`, + declared: { open: OPEN, share: SHARE }, + hosts, + }), + { open: OPEN, share: SHARE } + ); + // A fragment-bearing App CR url is not a probeable entry at all. + assert.deepEqual( + resolveTemplateEntryUrls({ + appUrl: `https://${HOST}/#token=abc`, + declared: {}, + hosts, + }), + {} + ); +}); + +test("an entry no Ingress of the deployment serves is dropped", () => { + assert.deepEqual( + resolveTemplateEntryUrls({ + appUrl: "https://elsewhere.example.sealos.run/", + declared: { share: "https://elsewhere.example.sealos.run/?x=1" }, + hosts: new Set([HOST]), + }), + {} + ); + assert.deepEqual( + resolveTemplateEntryUrls({ + declared: { open: `https://${HOST.toUpperCase()}/admin` }, + hosts: new Set([HOST]), + }), + { open: `https://${HOST.toUpperCase()}/admin` } + ); +}); + +test("templateEntryOpenPort follows the longest matching Ingress path to its Service port", () => { + const docs = eaglercraftDocs(); + assert.deepEqual(templateEntryOpenPort({ docs, openUrl: OPEN }), { + port: 5201, + serviceName: "eaglercraft", + }); + assert.deepEqual( + templateEntryOpenPort({ docs, openUrl: `https://${HOST}/admin/users` }), + { port: 5201, serviceName: "eaglercraft" } + ); + // `/admin` is not a prefix of `/administrator`; the root rule wins there. + assert.deepEqual( + templateEntryOpenPort({ docs, openUrl: `https://${HOST}/administrator` }), + { port: 5200, serviceName: "eaglercraft" } + ); + assert.deepEqual(templateEntryOpenPort({ docs, openUrl: SHARE }), { + port: 5200, + serviceName: "eaglercraft", + }); +}); + +test("templateEntryOpenPort resolves a named backend port through the Service", () => { + const docs = [ + service(), + ingress("eaglercraft", [{ path: "/", port: "admin" }]), + ]; + assert.deepEqual( + templateEntryOpenPort({ docs, openUrl: `https://${HOST}/` }), + { + port: 5201, + serviceName: "eaglercraft", + } + ); +}); + +test("templateEntryOpenPort finds nothing without a host, path, or Service match", () => { + const docs = eaglercraftDocs(); + assert.equal( + templateEntryOpenPort({ + docs, + openUrl: "https://elsewhere.example.sealos.run/admin", + }), + undefined + ); + assert.equal( + templateEntryOpenPort({ + docs: [ingress("eaglercraft", [{ path: "/", port: 5200 }])], + openUrl: OPEN, + }), + undefined + ); + assert.equal( + templateEntryOpenPort({ + docs: [service(), ingress("eaglercraft", [{ path: "/", port: 9999 }])], + openUrl: OPEN, + }), + undefined + ); + assert.equal( + templateEntryOpenPort({ docs, openUrl: "not a url" }), + undefined + ); +}); + +test("stampTemplateEntryOpenPort writes the annotation unless the template preset one", () => { + const fresh = service(); + assert.equal( + stampTemplateEntryOpenPort([fresh], { + port: 5201, + serviceName: "eaglercraft", + }), + true + ); + assert.equal( + fresh.metadata.annotations?.["brain.io/default-open-port"], + "5201" + ); + + const preset = service({ "brain.io/default-open-port": "5200" }); + assert.equal( + stampTemplateEntryOpenPort([preset], { + port: 5201, + serviceName: "eaglercraft", + }), + false + ); + assert.equal( + preset.metadata.annotations?.["brain.io/default-open-port"], + "5200" + ); + + assert.equal( + stampTemplateEntryOpenPort([service()], { port: 80, serviceName: "other" }), + false + ); +}); + +test("applyTemplateEntries renders, resolves, and presets the Default Open Port", () => { + const docs = eaglercraftDocs(); + const entries = applyTemplateEntries({ + declared: { + open: `https://${APP_HOST_EXPRESSION}.example.sealos.run/admin`, + share: `https://${APP_HOST_EXPRESSION}.example.sealos.run/?server=wss://${APP_HOST_EXPRESSION}.example.sealos.run/`, + }, + docs, + render: (value) => value.replaceAll(APP_HOST_EXPRESSION, "eagler-demo"), + }); + assert.deepEqual(entries, { open: OPEN, share: SHARE }); + const stamped = docs[0] as ReturnType; + assert.equal( + stamped.metadata.annotations?.["brain.io/default-open-port"], + "5201" + ); +}); + +test("applyTemplateEntries yields nothing for a template with neither entry nor App CR", () => { + const docs = [service(), ingress("eaglercraft", [{ path: "/", port: 5200 }])]; + assert.equal( + applyTemplateEntries({ declared: {}, docs, render: (value) => value }), + undefined + ); + assert.equal( + (docs[0] as ReturnType).metadata.annotations, + undefined + ); +}); + +test("templateInstanceDefaults keeps only resolved default values", () => { + assert.deepEqual( + templateInstanceDefaults({ + spec: { + defaults: { + app_host: { type: "string", value: "eagler-demo" }, + app_name: "eaglercraft", + unresolved: { value: `${TEMPLATE_EXPRESSION_START} random(8) }}` }, + bad: 3, + }, + }, + }), + { app_host: "eagler-demo", app_name: "eaglercraft" } + ); + assert.deepEqual(templateInstanceDefaults(null), {}); +}); diff --git a/apps/ui/src/features/deploy/template-entries.ts b/apps/ui/src/features/deploy/template-entries.ts new file mode 100644 index 000000000..822ac541e --- /dev/null +++ b/apps/ui/src/features/deploy/template-entries.ts @@ -0,0 +1,401 @@ +import { BRAIN_DEFAULT_OPEN_PORT_ANNOTATION } from "@/lib/brain-labels"; +import { ingressEntryPath } from "./task/ingress-entry-path"; + +/** + * Template Entries (ADR 0081): the `spec.entries` a Sealos Template may + * declare — `open`, the URL the Open control opens after a template + * deployment, and `share`, the URL the Success Record's share strip shares. + * + * Everything here is pure and runs over rendered Kubernetes documents (plain + * objects), so the same rules serve Brain's own renderer and the template + * provider's read-back path. Resolution rules: + * + * - Open: the declared `entries.open`; else the template's Sealos App CR + * (`app.sealos.io/v1` `App`, `spec.data.url`); else nothing here — the + * automatic Default Open Port rule takes over downstream. + * - Share: the declared `entries.share` only. It never falls back to the App + * CR URL, which may embed a secret (`#token=`, `/invite/`); the + * record falls back to the Open URL instead. + * - An entry is kept only when an Ingress of the same deployment serves its + * host: Brain never surfaces an address the deployment did not create. + */ + +export interface TemplateDeclaredEntries { + open?: string; + share?: string; +} + +export interface TemplateEntryUrls { + open?: string; + share?: string; +} + +/** The Service port an Open URL enters through: the Default Open Port preset. */ +export interface TemplateEntryOpenPort { + port: number; + serviceName: string; +} + +const SEALOS_APP_API_VERSION = "app.sealos.io/v1"; +const ENTRY_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); +const MAX_ENTRY_URL_LENGTH = 2048; + +function objectValue(value: unknown): Record | null { + return value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" + ? value.trim() + : undefined; +} + +function portNumber(value: unknown): number | undefined { + let port = Number.NaN; + if (typeof value === "number") { + port = value; + } else if (typeof value === "string" && value.trim() !== "") { + port = Number(value); + } + return Number.isInteger(port) && port > 0 && port <= 65_535 + ? port + : undefined; +} + +/** The raw `spec.entries` strings of a Template object, before rendering. */ +export function templateDeclaredEntries( + templateYaml: unknown +): TemplateDeclaredEntries { + const spec = objectValue(objectValue(templateYaml)?.spec); + const entries = objectValue(spec?.entries); + const open = stringValue(entries?.open); + const share = stringValue(entries?.share); + return { + ...(open === undefined ? {} : { open }), + ...(share === undefined ? {} : { share }), + }; +} + +/** + * A declared entry as a usable absolute URL, or nothing. The query string is + * kept verbatim (a share link may carry one); credentials and fragments are + * rejected because a fragment never reaches the server and cannot be probed. + */ +export function templateEntryUrl(value: unknown): string | undefined { + const raw = stringValue(value); + if (raw === undefined || raw.length > MAX_ENTRY_URL_LENGTH) { + return undefined; + } + try { + const url = new URL(raw); + if ( + !ENTRY_PROTOCOLS.has(url.protocol) || + url.username !== "" || + url.password !== "" || + url.hash !== "" || + url.hostname === "" + ) { + return undefined; + } + return raw; + } catch { + return undefined; + } +} + +function entryHostname(url: string): string | undefined { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return undefined; + } +} + +function isIngressDoc(doc: Record): boolean { + const apiVersion = stringValue(doc.apiVersion) ?? ""; + return doc.kind === "Ingress" && apiVersion.startsWith("networking.k8s.io/"); +} + +function isServiceDoc(doc: Record): boolean { + return doc.kind === "Service" && (doc.apiVersion ?? "v1") === "v1"; +} + +function docName(doc: Record): string | undefined { + return stringValue(objectValue(doc.metadata)?.name); +} + +/** The Sealos App CR URL (`spec.data.url`) a template applies for the desktop. */ +export function templateAppUrlFromDocs( + docs: readonly unknown[] +): string | undefined { + for (const value of docs) { + const doc = objectValue(value); + if ( + doc == null || + doc.kind !== "App" || + doc.apiVersion !== SEALOS_APP_API_VERSION + ) { + continue; + } + const url = stringValue(objectValue(objectValue(doc.spec)?.data)?.url); + if (url !== undefined) { + return url; + } + } + return undefined; +} + +/** Every hostname an Ingress rule of the deployment serves, lower-cased. */ +export function templateIngressHostsFromDocs( + docs: readonly unknown[] +): Set { + const hosts = new Set(); + for (const value of docs) { + const doc = objectValue(value); + if (doc == null || !isIngressDoc(doc)) { + continue; + } + const rules = objectValue(doc.spec)?.rules; + for (const rule of Array.isArray(rules) ? rules : []) { + const host = stringValue(objectValue(rule)?.host)?.toLowerCase(); + if (host !== undefined) { + hosts.add(host); + } + } + } + return hosts; +} + +/** + * The Open and Share URLs a deployment declares. `hosts` — the Ingress hosts + * of the same deployment — gates both: an entry no Ingress serves is dropped. + */ +export function resolveTemplateEntryUrls(input: { + appUrl?: string; + declared: TemplateDeclaredEntries; + hosts: ReadonlySet; +}): TemplateEntryUrls { + const served = (candidate: string | undefined): string | undefined => { + const url = templateEntryUrl(candidate); + if (url === undefined) { + return undefined; + } + const host = entryHostname(url); + return host !== undefined && input.hosts.has(host) ? url : undefined; + }; + const open = served(input.declared.open) ?? served(input.appUrl); + const share = served(input.declared.share); + return { + ...(open === undefined ? {} : { open }), + ...(share === undefined ? {} : { share }), + }; +} + +interface IngressBackendMatch { + pathLength: number; + port: unknown; + serviceName: string; +} + +function ingressBackendsForUrl( + doc: Record, + url: URL +): IngressBackendMatch[] { + const host = url.hostname.toLowerCase(); + const wantedPath = url.pathname === "" ? "/" : url.pathname; + const rules = objectValue(doc.spec)?.rules; + const matches: IngressBackendMatch[] = []; + for (const ruleValue of Array.isArray(rules) ? rules : []) { + const rule = objectValue(ruleValue); + if (stringValue(rule?.host)?.toLowerCase() !== host) { + continue; + } + const paths = objectValue(rule?.http)?.paths; + for (const pathValue of Array.isArray(paths) ? paths : []) { + const entry = objectValue(pathValue); + const path = ingressEntryPath(entry?.path); + if (path == null || !isPathPrefix(path, wantedPath)) { + continue; + } + const service = objectValue(objectValue(entry?.backend)?.service); + const serviceName = stringValue(service?.name); + if (serviceName === undefined) { + continue; + } + matches.push({ + pathLength: path.length, + port: service?.port, + serviceName, + }); + } + } + return matches; +} + +/** `/admin` is a prefix of `/admin` and `/admin/x`, never of `/administrator`. */ +function isPathPrefix(prefix: string, path: string): boolean { + if (prefix === "/") { + return true; + } + const bare = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; + return path === bare || path.startsWith(`${bare}/`); +} + +function servicePortNumber( + service: Record, + backendPort: unknown +): number | undefined { + const backend = objectValue(backendPort); + const number = portNumber(backend?.number); + const ports = objectValue(service.spec)?.ports; + const entries = (Array.isArray(ports) ? ports : []).flatMap((value) => { + const entry = objectValue(value); + return entry == null ? [] : [entry]; + }); + if (number !== undefined) { + return entries.some((entry) => portNumber(entry.port) === number) + ? number + : undefined; + } + const name = stringValue(backend?.name); + if (name === undefined) { + return undefined; + } + const named = entries.find((entry) => stringValue(entry.name) === name); + return named === undefined ? undefined : portNumber(named.port); +} + +/** + * The Service port the Open URL enters through: the URL host matched to an + * Ingress rule host, the rule path that is the longest prefix of the URL + * path, and that path's backend Service and port (a named port resolved + * through the Service's own `spec.ports`). Nothing when no rule matches or + * the Service is not among the documents. + */ +export function templateEntryOpenPort(input: { + docs: readonly unknown[]; + openUrl: string; +}): TemplateEntryOpenPort | undefined { + let url: URL; + try { + url = new URL(input.openUrl); + } catch { + return undefined; + } + const records = input.docs.flatMap((value) => { + const doc = objectValue(value); + return doc == null ? [] : [doc]; + }); + const matches = records + .filter(isIngressDoc) + .flatMap((doc) => ingressBackendsForUrl(doc, url)) + .sort((a, b) => b.pathLength - a.pathLength); + for (const match of matches) { + const service = records.find( + (doc) => isServiceDoc(doc) && docName(doc) === match.serviceName + ); + if (service === undefined) { + continue; + } + const port = servicePortNumber(service, match.port); + if (port !== undefined) { + return { port, serviceName: match.serviceName }; + } + } + return undefined; +} + +/** + * Writes the Default Open Port preset onto the matched Service document, + * unless the template already set one. Returns whether the document changed. + */ +export function stampTemplateEntryOpenPort( + docs: readonly unknown[], + target: TemplateEntryOpenPort +): boolean { + for (const value of docs) { + const doc = objectValue(value); + if ( + doc == null || + !isServiceDoc(doc) || + docName(doc) !== target.serviceName + ) { + continue; + } + const metadata = objectValue(doc.metadata) ?? {}; + doc.metadata = metadata; + const annotations = objectValue(metadata.annotations) ?? {}; + metadata.annotations = annotations; + if ( + stringValue(annotations[BRAIN_DEFAULT_OPEN_PORT_ANNOTATION]) !== undefined + ) { + return false; + } + annotations[BRAIN_DEFAULT_OPEN_PORT_ANNOTATION] = String(target.port); + return true; + } + return false; +} + +/** + * Resolves a template's entries over its rendered documents and presets the + * Default Open Port on the Service the Open URL enters through. `render` + * substitutes the template's `${{ }}` expressions; the caller owns the + * evaluation context. Returns the resolved URLs, or nothing when the + * template declares no usable entry. + */ +export function applyTemplateEntries(input: { + declared: TemplateDeclaredEntries; + docs: readonly unknown[]; + render: (value: string) => string; +}): TemplateEntryUrls | undefined { + const declared: TemplateDeclaredEntries = { + ...(input.declared.open === undefined + ? {} + : { open: input.render(input.declared.open) }), + ...(input.declared.share === undefined + ? {} + : { share: input.render(input.declared.share) }), + }; + const entries = resolveTemplateEntryUrls({ + appUrl: templateAppUrlFromDocs(input.docs), + declared, + hosts: templateIngressHostsFromDocs(input.docs), + }); + if (entries.open !== undefined) { + const target = templateEntryOpenPort({ + docs: input.docs, + openUrl: entries.open, + }); + if (target !== undefined) { + stampTemplateEntryOpenPort(input.docs, target); + } + } + return entries.open === undefined && entries.share === undefined + ? undefined + : entries; +} + +/** + * The resolved default values an applied Instance CR carries (`spec.defaults` + * as the provider rendered them), for re-rendering entries after a provider + * deployment. A value still holding a `${{ }}` expression was not resolved + * and is left out, so the Ingress-host gate above drops what it would feed. + */ +export function templateInstanceDefaults( + instance: unknown +): Record { + const defaults = objectValue( + objectValue(objectValue(instance)?.spec)?.defaults + ); + const out: Record = {}; + for (const [key, entry] of Object.entries(defaults ?? {})) { + const value = typeof entry === "string" ? entry : objectValue(entry)?.value; + if (typeof value === "string" && !value.includes("${{")) { + out[key] = value; + } + } + return out; +} diff --git a/apps/ui/src/features/deploy/template-renderer.test.ts b/apps/ui/src/features/deploy/template-renderer.test.ts index 186013f59..153fa29df 100644 --- a/apps/ui/src/features/deploy/template-renderer.test.ts +++ b/apps/ui/src/features/deploy/template-renderer.test.ts @@ -1227,3 +1227,206 @@ metadata: EMPTY_TEMPLATE_RESOURCE_SET_RE ); }); + +const ENTRIES_APP_HOST_EXPRESSION = `${TEMPLATE_EXPRESSION_START} defaults.app_host }}.${TEMPLATE_EXPRESSION_START} SEALOS_CLOUD_DOMAIN }}`; + +const ENTRIES_TEMPLATE_HEADER = `apiVersion: app.sealos.io/v1 +kind: Template +metadata: + name: eaglercraft-server +spec: + title: EaglerCraft Server + defaults: + app_host: + type: string + value: eagler-demo + app_name: + type: string + value: eaglercraft + inputs: {} +`; + +const ENTRIES_TEMPLATE_RESOURCES = `--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: \${{ defaults.app_name }} + labels: + app: \${{ defaults.app_name }} + cloud.sealos.io/app-deploy-manager: \${{ defaults.app_name }} +spec: + selector: + matchLabels: + app: \${{ defaults.app_name }} + template: + metadata: + labels: + app: \${{ defaults.app_name }} + spec: + containers: + - name: main + image: ghcr.io/example/eaglercraft:latest +--- +apiVersion: v1 +kind: Service +metadata: + name: \${{ defaults.app_name }} + labels: + app: \${{ defaults.app_name }} +spec: + ports: + - port: 5200 + targetPort: 5200 + name: game + - port: 5201 + targetPort: 5201 + name: admin + selector: + app: \${{ defaults.app_name }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: \${{ defaults.app_name }} + annotations: + nginx.ingress.kubernetes.io/backend-protocol: WS +spec: + rules: + - host: \${{ defaults.app_host }}.\${{ SEALOS_CLOUD_DOMAIN }} + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: \${{ defaults.app_name }} + port: + number: 5200 + tls: + - hosts: + - \${{ defaults.app_host }}.\${{ SEALOS_CLOUD_DOMAIN }} + secretName: wildcard-cert +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: \${{ defaults.app_name }}-admin +spec: + rules: + - host: \${{ defaults.app_host }}.\${{ SEALOS_CLOUD_DOMAIN }} + http: + paths: + - pathType: Prefix + path: /api + backend: + service: + name: \${{ defaults.app_name }} + port: + number: 5201 + - pathType: Prefix + path: /admin + backend: + service: + name: \${{ defaults.app_name }} + port: + number: 5201 + tls: + - hosts: + - \${{ defaults.app_host }}.\${{ SEALOS_CLOUD_DOMAIN }} + secretName: wildcard-cert +`; + +const ENTRIES_APP_CR = `--- +apiVersion: app.sealos.io/v1 +kind: App +metadata: + name: \${{ defaults.app_name }} +spec: + data: + url: https://\${{ defaults.app_host }}.\${{ SEALOS_CLOUD_DOMAIN }}/admin + type: link +`; + +function renderEntriesTemplate(input: { + entries?: string; + includeAppCr?: boolean; + serviceAnnotation?: string; +}) { + const header = + input.entries === undefined + ? ENTRIES_TEMPLATE_HEADER + : `${ENTRIES_TEMPLATE_HEADER} entries:\n${input.entries}\n`; + const resources = + input.serviceAnnotation === undefined + ? ENTRIES_TEMPLATE_RESOURCES + : ENTRIES_TEMPLATE_RESOURCES.replace( + "kind: Service\nmetadata:\n", + `kind: Service\nmetadata:\n annotations:\n ${input.serviceAnnotation}\n` + ); + return renderTemplateDeploymentFromYaml({ + instanceName: "eaglercraft", + namespace: "ns-demo", + projectId: "project-1", + projectName: "EaglerCraft", + routingDomain: "example.sealos.run", + templateYaml: `${header}${resources}${input.includeAppCr === false ? "" : ENTRIES_APP_CR}`, + }); +} + +function renderedServiceAnnotation( + rendered: ReturnType +): string | undefined { + const service = rendered.resources.find((doc) => doc.kind === "Service"); + const annotations = service?.metadata?.annotations as + | Record + | undefined; + return annotations?.["brain.io/default-open-port"]; +} + +test("renderTemplateDeployment renders Template Entries with the resource context and presets the Default Open Port", () => { + const rendered = renderEntriesTemplate({ + entries: [ + ` open: https://${ENTRIES_APP_HOST_EXPRESSION}/admin`, + ` share: https://${ENTRIES_APP_HOST_EXPRESSION}/?server=wss://${ENTRIES_APP_HOST_EXPRESSION}/`, + ].join("\n"), + }); + assert.deepEqual(rendered.entries, { + open: "https://eagler-demo.example.sealos.run/admin", + share: + "https://eagler-demo.example.sealos.run/?server=wss://eagler-demo.example.sealos.run/", + }); + assert.equal(renderedServiceAnnotation(rendered), "5201"); + // The annotation ships with the applied document, not only the object. + const serviceYaml = rendered.dependentYamls.find((yaml) => + yaml.includes("kind: Service") + ); + assert.ok(serviceYaml?.includes('brain.io/default-open-port: "5201"')); + // Entries never leak into the Instance the template creates. + assert.ok(!rendered.instanceYaml.includes("entries")); +}); + +test("renderTemplateDeployment falls back to the App CR url for Open and never for Share", () => { + const rendered = renderEntriesTemplate({}); + assert.deepEqual(rendered.entries, { + open: "https://eagler-demo.example.sealos.run/admin", + }); + assert.equal(renderedServiceAnnotation(rendered), "5201"); +}); + +test("renderTemplateDeployment declares no entry and writes no annotation without entries or App CR", () => { + const rendered = renderEntriesTemplate({ includeAppCr: false }); + assert.equal(rendered.entries, undefined); + assert.equal(renderedServiceAnnotation(rendered), undefined); +}); + +test("renderTemplateDeployment keeps a template's own Default Open Port preset and drops an entry no Ingress serves", () => { + const rendered = renderEntriesTemplate({ + entries: ` open: https://elsewhere.${TEMPLATE_EXPRESSION_START} SEALOS_CLOUD_DOMAIN }}/admin`, + serviceAnnotation: 'brain.io/default-open-port: "5200"', + }); + // The foreign Open entry is dropped; the App CR url stands in for it. + assert.deepEqual(rendered.entries, { + open: "https://eagler-demo.example.sealos.run/admin", + }); + assert.equal(renderedServiceAnnotation(rendered), "5200"); +}); diff --git a/apps/ui/src/features/deploy/template-renderer.ts b/apps/ui/src/features/deploy/template-renderer.ts index 4e6153c42..7abfaee9e 100644 --- a/apps/ui/src/features/deploy/template-renderer.ts +++ b/apps/ui/src/features/deploy/template-renderer.ts @@ -14,6 +14,12 @@ import { LAUNCHPAD_APP_LABEL, LAUNCHPAD_TEMPLATE_SOURCE_LABEL, } from "@/lib/brain-labels"; +import { + applyTemplateEntries, + type TemplateDeclaredEntries, + type TemplateEntryUrls, + templateDeclaredEntries, +} from "./template-entries"; import type { TemplateDefaultValue, TemplateSourceInput, @@ -72,6 +78,12 @@ export interface RenderTemplateDeploymentInput { export interface RenderedTemplateDeployment { dependentYamls: string[]; + /** + * Template Entries (ADR 0081): the rendered Open and Share URLs the + * template declared, the App CR URL standing in for an absent Open. + * Absent when the template declares neither. + */ + entries?: TemplateEntryUrls; instanceName: string; instanceYaml: string; resources: TemplateK8sObject[]; @@ -1804,11 +1816,20 @@ export function renderTemplateDeployment( ensureMetadata(instanceResource).name = input.instanceName; ensureLabels(ensureMetadata(instanceResource))[OWNER_REFERENCES_LABEL] = OWNER_REFERENCES_READY_VALUE; + // Template Entries render with the same context as every resource, and the + // Open URL presets the Default Open Port on the Service it enters through — + // before the documents are dumped, so the annotation ships with the apply. + const entries = applyTemplateEntries({ + declared: templateDeclaredEntries(input.source.templateYaml), + docs: resources, + render: (value) => renderTemplateString(value, context), + }); const dependents = resources.filter( (resource) => resource !== instanceResource ); return { dependentYamls: dependents.map(dumpObject), + ...(entries === undefined ? {} : { entries }), instanceName: input.instanceName, instanceYaml: dumpObject(instanceResource), resources, @@ -1824,6 +1845,37 @@ export function renderTemplateDeployment( }; } +/** + * Renders declared Template Entries outside a full render — for a template + * the provider applied, whose resolved defaults Brain reads back off the + * Instance CR and whose inputs it holds in memory. The evaluation context is + * the renderer's own, so an entry substitutes exactly as a resource would. + */ +export function renderTemplateEntryExpressions(input: { + certSecretName?: string; + declared: TemplateDeclaredEntries; + defaults: Record; + inputs: Record; + namespace: string; + platformValues?: Record; + routingDomain?: string; +}): TemplateDeclaredEntries { + const context: EvaluationContext = { + ...templatePlatformContext(input), + defaults: input.defaults, + inputs: input.inputs, + }; + const render = (value: string) => renderTemplateString(value, context); + return { + ...(input.declared.open === undefined + ? {} + : { open: render(input.declared.open) }), + ...(input.declared.share === undefined + ? {} + : { share: render(input.declared.share) }), + }; +} + export function renderTemplateDeploymentFromYaml( input: Omit & { templateYaml: string; diff --git a/apps/ui/src/lib/brain-labels.ts b/apps/ui/src/lib/brain-labels.ts index d7da1e646..e7bec5b49 100644 --- a/apps/ui/src/lib/brain-labels.ts +++ b/apps/ui/src/lib/brain-labels.ts @@ -57,3 +57,10 @@ export function managedTemplateDeploymentLabels( [BRAIN_DEPLOYMENT_KIND_LABEL]: "template", }; } + +/** + * Default Open Port (ADR 0080's store, ADR 0081's template preset): the App + * Listening Port whose best Public Address the Open control opens, stored on + * the AP's Service next to the Port Display Names. Display-only bookkeeping. + */ +export const BRAIN_DEFAULT_OPEN_PORT_ANNOTATION = "brain.io/default-open-port"; From 5489d59f8dd99e3d0b9f0da13371d92cf9ccde4b Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 8 Sep 2026 22:24:21 +0800 Subject: [PATCH 2/5] docs(adr): ADR-0081 declares Template Entries for Open and Share Records the 2026-09-08 decision that a template may declare its entries, with the fallback chains, the probe-verified-but-optional gate, the Service annotation preset, and the Ingress-host boundary. CONTEXT.md's Default Open Port no longer says a Template Instance has no open link and gains the Template Entry glossary term (Open Entry / Share Entry); the Deployment Access Endpoint and Success Record entries name the new source and the `shareUrl` snapshot. ADR-0079 and ADR-0080 get Status sections pointing at the amendment. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012nPEVVc7yiShA1SrfuTUqM --- CONTEXT.md | 14 +- ...y-points-as-deployment-access-endpoints.md | 9 ++ ...-port-display-names-live-on-the-service.md | 7 + ...are-template-entries-for-open-and-share.md | 130 ++++++++++++++++++ docs/adr/README.md | 1 + 5 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0081-declare-template-entries-for-open-and-share.md diff --git a/CONTEXT.md b/CONTEXT.md index 20fb5e228..e7b6ba371 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -80,9 +80,9 @@ _Avoid_: Public Address name, domain name label, port alias. ### Default Open Port -The App Listening Port that the Open control opens — on the AP Public Access Node header and in the pane header of every AP-owned Settings View — through its best Public Address: an accessible Custom Domain, else an accessible Platform Address; with neither, Open is shown disabled, with the reason. The node's control names the port ("Open "), its only marker there; the pane control reads just "Open" and names the port only on hover, since the App Listening Ports card beside it already shows the choice. A stored choice lives only on the AP's Service (annotation `brain.io/default-open-port`, next to Port Display Names), so a template can preset it; users set or clear it from the port's row in the App Listening Ports card ("Open by default"), and clearing returns to the automatic rule: the first App Listening Port, in declaration order, whose HTTP Public Address enters at the root; with none at the root, the first that has any HTTP Public Address — so a backend port routed under `/api` yields to the page port however the template ordered them. A stored port that has no HTTP Public Address is ignored, not surfaced. Ports reached only by WS/WSS Public Addresses are never chosen. Owned by one AP; there is no Project-level or Template-Instance-level open link. +The App Listening Port that the Open control opens — on the AP Public Access Node header and in the pane header of every AP-owned Settings View — through its best Public Address: an accessible Custom Domain, else an accessible Platform Address; with neither, Open is shown disabled, with the reason. The node's control names the port ("Open "), its only marker there; the pane control reads just "Open" and names the port only on hover, since the App Listening Ports card beside it already shows the choice. A stored choice lives only on the AP's Service (annotation `brain.io/default-open-port`, next to Port Display Names), so a template can preset it; users set or clear it from the port's row in the App Listening Ports card ("Open by default"), and clearing returns to the automatic rule: the first App Listening Port, in declaration order, whose HTTP Public Address enters at the root; with none at the root, the first that has any HTTP Public Address — so a backend port routed under `/api` yields to the page port however the template ordered them. A stored port that has no HTTP Public Address is ignored, not surfaced. Ports reached only by WS/WSS Public Addresses are never chosen. Owned by one AP. A template presets it through its Template Entries: at render time Brain follows the Open Entry — the declared one, else the template's Sealos App CR url — through the Ingress rule whose host and longest-prefix path serve it to the Service port behind it, and writes the annotation there unless the template already set it, so the node's Open and the Deployment Task Success Record's Open agree; no match writes nothing (ADR 0081). -_Avoid_: primary entry, primary port, primary address, launch link, main domain. +_Avoid_: primary entry, primary port, primary address, launch link, main domain, Project open link, Template Instance open link. ### Private Address @@ -354,10 +354,16 @@ _Avoid_: applied object, Kubernetes object. ### Deployment Access Endpoint -A source-independent Deployment Result Resource describing one user-facing way to reach a deployed product. It has a stable task-local identity, explicit HTTP or WebSocket protocol, a provider observer or declared URL, and an independently verified readiness state. Docker AP addresses, Template Ingress hosts, and GitHub Agent-declared URLs all converge on this contract. The observer resolves the provider's actual address; Brain never reconstructs an address or infers WSS from HTTPS. Once verified, an endpoint that reaches an App Listening Port of one of the task's APs — an AP Public Address, or a Template Ingress host the AP observed — is named by that port's Port Display Name form; an Agent-declared URL keeps its declared label. +A source-independent Deployment Result Resource describing one user-facing way to reach a deployed product. It has a stable task-local identity, explicit HTTP or WebSocket protocol, a provider observer or declared URL, and an independently verified readiness state. Docker AP addresses, Template Ingress hosts, GitHub Agent-declared URLs, and Template Entries all converge on this contract. The observer resolves the provider's actual address; Brain never reconstructs an address or infers WSS from HTTPS. Once verified, an endpoint that reaches an App Listening Port of one of the task's APs — an AP Public Address, a Template Ingress host the AP observed, or a template's Open Entry — is named by that port's Port Display Name form; an Agent-declared URL and a Share Entry keep their declared label. _Avoid_: guessed URL, inferred socket address, source-specific public access card. +### Template Entry + +A URL a Sealos Template declares in its header (`spec.entries`) for one of two roles, each a full URL rendered with the template's own `${{ }}` substitution: the Open Entry is what the Open control opens after the deployment, and the Share Entry is what the Deployment Task Success Record's share strip shares. When a template declares no Open Entry, its Sealos App CR url (`spec.data.url`) stands in; with neither, the Default Open Port's automatic rule decides. The Share Entry has one fallback only, the Open URL — never the App CR url, which may embed a secret. Each is a Deployment Access Endpoint with a declared URL, probed verbatim (query string kept, matched by full URL) and admitted to the record only once verified; it is optional evidence, never a completion gate, and an unverified one simply yields to its fallback. An entry whose host no Ingress of the deployment serves is dropped. The Open Entry also presets the AP's Default Open Port (ADR 0081). + +_Avoid_: primary entry, launch link, main domain, share project, share deployment, App CR link (as the product term), entry point (for the field). + ### Deployment Result Readiness The condition where a task's user-visible result resources have become healthy enough for the task to count as complete — distinct from having applied Deployment Artifacts. Raw Kubernetes resources use one task-facing predicate per Kind across deterministic and Agent-managed runners: replica controllers require their Ready counts, Pods and Jobs require their Ready/Complete conditions, and a non-suspended CronJob is ready without waiting for a scheduled execution. @@ -380,7 +386,7 @@ A Deployment Task Timeline section for one Deployment Result Resource, presentin ### Deployment Task Success Record -The conclusion a Deployment Task Timeline appends once Deployment Result Readiness is reached and every required access endpoint has passed its protocol probe. It carries only facts the deployment declared — product name, verified HTTP or WebSocket entries, first-use steps — so the Timeline never presents an address or instruction the runner cannot evidence. Each entry is headed the way a Public Address is shown everywhere else: the Port Display Name form of the App Listening Port it reaches (`game · 5200`, or `5200` alone for an unnamed port), as it stood at verification time; an entry no App Listening Port can be found for keeps the name its source declared. Two entries reaching the same port carry the same heading; the record stays a flat list. A record with a single entry heads it with nothing, whatever heading its source would have given it, as the node draws a lone Public Address. HTTP(S) entries can be opened and copied; WS(S) entries are copied. Its Open control opens the Default Open Port through its best Public Address as decided when the record was written — the record is a snapshot, so a later rename or Default Open Port change does not rewrite it. A verified deployment with no endpoint uses the neutral `Deployment completed` headline, while `You can start using it` is reserved for a verified actionable entry. A task with no required Deployment Result Resource publishes no record and keeps reporting progress. It is part of the task-owned timeline snapshot, not a Chat message or a toast, and its Timeline revision doubles as its identity. Its primary HTTP(S) entry may be shared — copied, shown as a QR code, or posted to a social network — as the product's own public address; this shares nothing of Brain and is not Public Project Preview Sharing, which no longer exists. Its first-use steps appear under the user-facing heading `Next steps`, and only when the deployment declared them; a record without declared steps shows no heading. A record written for a template deployment also snapshots the template's catalog name as its product id and the template's declared categories (`game`, `ai`, …) as they stood when the task was created, so the share copy can speak to a game or an AI app without asking the catalog again; other sources declare neither. +The conclusion a Deployment Task Timeline appends once Deployment Result Readiness is reached and every required access endpoint has passed its protocol probe. It carries only facts the deployment declared — product name, verified HTTP or WebSocket entries, first-use steps — so the Timeline never presents an address or instruction the runner cannot evidence. Each entry is headed the way a Public Address is shown everywhere else: the Port Display Name form of the App Listening Port it reaches (`game · 5200`, or `5200` alone for an unnamed port), as it stood at verification time; an entry no App Listening Port can be found for keeps the name its source declared. Two entries reaching the same port carry the same heading; the record stays a flat list. A record with a single entry heads it with nothing, whatever heading its source would have given it, as the node draws a lone Public Address. HTTP(S) entries can be opened and copied; WS(S) entries are copied. Its Open control opens the template's verified Open Entry when there is one, else the Default Open Port through its best Public Address, as decided when the record was written — the record is a snapshot, so a later rename or Default Open Port change does not rewrite it. A verified deployment with no endpoint uses the neutral `Deployment completed` headline, while `You can start using it` is reserved for a verified actionable entry. A task with no required Deployment Result Resource publishes no record and keeps reporting progress. It is part of the task-owned timeline snapshot, not a Chat message or a toast, and its Timeline revision doubles as its identity. Its share address — the template's verified Share Entry, else its Open URL, snapshotted as `shareUrl` (a record written before the field shares its primary HTTP(S) entry) — may be shared: copied, shown as a QR code, or posted to a social network as the product's own public address; this shares nothing of Brain and is not Public Project Preview Sharing, which no longer exists. Its first-use steps appear under the user-facing heading `Next steps`, and only when the deployment declared them; a record without declared steps shows no heading. A record written for a template deployment also snapshots the template's catalog name as its product id and the template's declared categories (`game`, `ai`, …) as they stood when the task was created, so the share copy can speak to a game or an AI app without asking the catalog again; other sources declare neither. _Avoid_: success toast, deploy done banner, completion notification, "Public address" as an entry heading, share project, share deployment. diff --git a/docs/adr/0079-model-public-entry-points-as-deployment-access-endpoints.md b/docs/adr/0079-model-public-entry-points-as-deployment-access-endpoints.md index 79e56be72..8b5bff736 100644 --- a/docs/adr/0079-model-public-entry-points-as-deployment-access-endpoints.md +++ b/docs/adr/0079-model-public-entry-points-as-deployment-access-endpoints.md @@ -1,5 +1,14 @@ # Model Public Entry Points as Deployment Access Endpoints +## Status + +Accepted; amended in place (the entry-path rule, below) and extended by +ADR-0081, which adds a template-declared observer kind (Template Entries) to +the declared-URL family. Template Entries are probed under this ADR's +contract but are optional evidence: they do not gate completion the way an +Agent-declared endpoint does, and CONTEXT.md's former "no Template-Instance- +level open link" no longer holds. + ## Context Deployment Tasks exposed user-facing addresses through three incompatible diff --git a/docs/adr/0080-port-display-names-live-on-the-service.md b/docs/adr/0080-port-display-names-live-on-the-service.md index cb936a07e..0c5b168b3 100644 --- a/docs/adr/0080-port-display-names-live-on-the-service.md +++ b/docs/adr/0080-port-display-names-live-on-the-service.md @@ -1,5 +1,12 @@ # Port Display Names Live on the Service, Not the Ingress +## Status + +Accepted. ADR-0081 adds a second writer for the Default Open Port store +named under Consequences: Brain itself presets `brain.io/default-open-port` +from a template's Template Entries (or its App CR url) at render or +read-back time, only where the template left the annotation empty. + An AP with several App Listening Ports (game 5200 / admin 5201, login 3001 / admin 3002, S3 API 9000 / console 9001) gets one Public Address per port, and the AP Public Access Node showed those as rows that differed only in hostname. diff --git a/docs/adr/0081-declare-template-entries-for-open-and-share.md b/docs/adr/0081-declare-template-entries-for-open-and-share.md new file mode 100644 index 000000000..a0f1cce64 --- /dev/null +++ b/docs/adr/0081-declare-template-entries-for-open-and-share.md @@ -0,0 +1,130 @@ +# Declare Template Entries for Open and Share + +## Context + +After a template deployment, Brain decides two addresses on the user's +behalf: the one its Open control opens, and the one the Deployment Task +Success Record's share strip copies, shows as a QR code, or posts. Both were +inferred. Open followed the Default Open Port rule (CONTEXT.md; ADR 0079's +amendment; ADR 0080's store) over the AP's observed Public Addresses, and the +share strip shared whatever came first. For most catalog templates that is +right. For a product whose page is not the routing root it is not: the +EaglerCraft template serves a WebSocket game at `/` and its admin console at +`/admin`, and its browser client is joined through a query-string URL +(`/?server=wss://…`) that no Ingress path names. No rule over Ingress paths +can know that the console is what to open and the join link is what to +share — only the template author does. + +Two facts made the gap avoidable. 212 of the 256 catalog templates already +apply a Sealos App CR (`app.sealos.io/v1` `App`) whose `spec.data.url` is the +address the Sealos desktop launcher opens; Brain applied that object and never +read it. And ADR 0079 already has a contract for a declared, probe-verified +URL — the one GitHub Agent-declared endpoints use. + +CONTEXT.md's Default Open Port entry said "there is no Project-level or +Template-Instance-level open link". The product owner decided on 2026-09-08 +that a template may declare its entries; this record documents that decision +and revises the sentence. + +## Decision + +A Sealos Template may declare **Template Entries** in its header: + +```yaml +spec: + entries: + open: https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}/admin + share: https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}/?server=wss://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}/ +``` + +Both are full URLs rendered with the same `${{ }}` substitution as every +resource document (defaults, inputs, `SEALOS_*` values). Both are optional. + +**Open** is the URL the Open control opens after the deployment. Its fallback +chain: the declared `entries.open`; else the template's App CR +`spec.data.url`; else the automatic Default Open Port rule, unchanged. + +**Share** is the URL the Success Record's share strip shares. Its fallback: +the declared `entries.share`; else the Open URL, which is what the strip +shared before. Share never falls back to the App CR URL on its own: some App +CR URLs embed a secret (`#token=…`, `/invite/`) that must not be posted +to a social network. It only ever equals `entries.share` or the resolved Open +URL. + +**Verification.** A Template Entry is a Deployment Access Endpoint with a +declared URL — ADR 0079's contract. It is probed like any HTTP or WebSocket +endpoint, verbatim: a share URL keeps its query string, the GET is on the +full URL, and endpoints are de-duplicated by full URL, so an Open entry that +names the address an Ingress card already observes adds no second card. Only +a verified entry enters the Success Record. Unlike an Agent-declared +endpoint, a Template Entry does not gate completion: the Ingress-derived +entries already gate usability, and a declared link that fails its probe is +left out of the record rather than failing a deployment that works. A +declared Open entry the probe did not confirm leaves the automatic rule in +charge; an unconfirmed Share entry leaves the Open URL shared. + +**Consistency with the AP.** When the Open URL — declared or from the App CR +— can be matched to one App Listening Port of one of the deployment's APs, +Brain writes `brain.io/default-open-port: ""` onto that AP's Service +at render time, unless the template already set it. The match: the URL host +equals an Ingress rule host in the rendered documents; the rule path that is +the longest prefix of the URL path is followed to its backend Service and +port (a named port resolved through the Service's own `spec.ports`). No +match writes nothing. The annotation is Brain's own bookkeeping (ADR 0080's +store, read through `status.network.defaultOpenPort`), so the AP Public +Access Node's Open and the record's Open agree. + +**Boundary.** An entry is kept only when an Ingress of the same deployment +serves its host. Brain never surfaces an address the deployment did not +create, and — for a provider-applied template whose defaults Brain re-reads +off the Instance CR — an unresolved expression can never leak into a URL. +Credentials and fragments disqualify an entry outright. + +**Where it runs.** Brain renders some templates itself and defers others to +the template provider. In Brain's renderer the rules run over the documents +it is about to apply, and the annotation ships with the Service. For a +provider-applied template Brain reads the same facts back once the Ingresses +exist — the declared entries off the template source, the resolved defaults +off the Instance CR, the inputs from the run's memory, the Ingresses, +Services, and App CR from the cluster — then applies the same pure rules and +patches the matched Service. Entries are advisory there: a failed read +degrades to no declared entry, never to a failed deployment. + +**The record.** The Success Record snapshots its share address as +`shareUrl`, next to the entries. A record written before the field existed +shares its primary HTTP(S) entry, as it always did. + +## Considered Options + +- **Keep inferring from Ingress paths** — rejected. The entry-path rule (ADR + 0079 amendment) already picks the best routing candidate; a join link with + a query string is not a routing candidate at all. +- **Read only the App CR, no `entries` field** — rejected as the whole + answer, kept as the fallback. The App CR URL is written for the Sealos + desktop launcher and may carry a secret, so it can preset Open but never + Share; and it cannot name a second, share-specific URL. +- **Let Share fall back to the App CR URL** — rejected: `#token=` and + `/invite/` URLs exist in the catalog today. +- **Make Template Entries gate completion like Agent-declared URLs** — + rejected for this field: 212 templates would gain a new required probe + overnight, and the Ingress-derived entries already prove usability. +- **Name the port on the Ingress or a new CR** — rejected; ADR 0080 settled + that port facts live on the Service. + +## Consequences + +- CONTEXT.md's Default Open Port no longer says a Template Instance has no + open link: a template presets its AP's Default Open Port through its + entries, and the store, read path, and user override stay exactly ADR + 0080's. The glossary gains Template Entry. +- ADR 0079's "declared URL" observer family gains a template-declared kind + that is named like an Ingress host once verified (Open) or keeps its + declared label (Share); its statement that a failed required probe + prevents completion is unchanged, since Template Entry cards are optional. +- ADR 0080's note that "a template can preset" the Default Open Port now has + a second writer: Brain itself, from the template's entries, at render or + read-back time, and only where the template left the annotation empty. +- Template authors get one explicit place to say what opens and what is + shared; the 212 App CR templates get the right Open without editing. +- The Success Record contract gains an optional `shareUrl`; readers keep + accepting records without it. diff --git a/docs/adr/README.md b/docs/adr/README.md index f43e6d6d5..c112b3853 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,6 +52,7 @@ One line per decision; the linked record is authoritative. When adding an ADR, t - [0078 — Conclude Deployment Tasks with an Evidence-Gated Success Record](0078-conclude-deployment-tasks-with-an-evidence-gated-success-record.md) *(extends ADR-0028 with the conclusion that timeline was missing; the failure counterpart is ADR-0042)* - [0079 — Model Public Entry Points as Deployment Access Endpoints](0079-model-public-entry-points-as-deployment-access-endpoints.md) *(revises ADR-0078's address and success-copy contract)* - [0080 — Port Display Names Live on the Service, Not the Ingress](0080-port-display-names-live-on-the-service.md) *(extends ADR-0066's annotation pattern to App Listening Ports; carves out a read-time fallback exception; complements ADR-0079's Deployment Access Endpoint labels)* +- [0081 — Declare Template Entries for Open and Share](0081-declare-template-entries-for-open-and-share.md) *(extends ADR-0079's declared-URL endpoints to template-declared entries; adds a Brain-side writer for ADR-0080's Default Open Port store; revises CONTEXT.md's "no Template-Instance-level open link")* ## Conventions From 775f2696708694e67190e2df756738b4c0bda143 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Wed, 9 Sep 2026 00:25:55 +0800 Subject: [PATCH 3/5] feat(deploy): take Template Entries as declared instead of probing them Decided with the product owner on 2026-09-09. A Template Entry's host is an Ingress host of the same deployment, and every such host is a required Deployment Access Endpoint whose probe already gates the Success Record. A second probe of the full URL would only test whether the application answers on that path, which the Public Address Health definition already excludes from health; and as optional evidence it silently swapped the declared Open for the automatic rule while the Service annotation named the declared port. So entries are no longer Deployment Access Endpoint cards. The record takes them as declared: the Open Entry is its first entry, sharing the Ingress card's entry when one lists the same URL, else added and headed by the App Listening Port it reaches when an AP observed that address. The Share Entry is only the record's share address and is never listed as an entry. shareUrl is still written on every new record, and the App CR fallback stays a fallback recorded only in ADR-0081. ADR-0081's verification section, ADR-0079's Status, the ADR index line, and CONTEXT.md's Template Entry, Deployment Access Endpoint, and Success Record entries say the same. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014ZuqvxhtEq1d99vxZSaiLE --- CONTEXT.md | 6 +- .../deploy/task/direct-timeline.test.ts | 101 -------------- .../features/deploy/task/direct-timeline.ts | 76 ---------- .../deploy/task/result-readiness.test.ts | 130 ------------------ .../features/deploy/task/result-readiness.ts | 26 ++-- apps/ui/src/features/deploy/task/runner.ts | 41 +++--- .../src/features/deploy/task/timeline.test.ts | 95 +++++++------ apps/ui/src/features/deploy/task/timeline.ts | 84 ++++++----- .../src/features/deploy/template-entries.ts | 2 +- ...y-points-as-deployment-access-endpoints.md | 12 +- ...are-template-entries-for-open-and-share.md | 57 +++++--- docs/adr/README.md | 2 +- 12 files changed, 180 insertions(+), 452 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index e7b6ba371..6112841ce 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -354,13 +354,13 @@ _Avoid_: applied object, Kubernetes object. ### Deployment Access Endpoint -A source-independent Deployment Result Resource describing one user-facing way to reach a deployed product. It has a stable task-local identity, explicit HTTP or WebSocket protocol, a provider observer or declared URL, and an independently verified readiness state. Docker AP addresses, Template Ingress hosts, GitHub Agent-declared URLs, and Template Entries all converge on this contract. The observer resolves the provider's actual address; Brain never reconstructs an address or infers WSS from HTTPS. Once verified, an endpoint that reaches an App Listening Port of one of the task's APs — an AP Public Address, a Template Ingress host the AP observed, or a template's Open Entry — is named by that port's Port Display Name form; an Agent-declared URL and a Share Entry keep their declared label. +A source-independent Deployment Result Resource describing one user-facing way to reach a deployed product. It has a stable task-local identity, explicit HTTP or WebSocket protocol, a provider observer or declared URL, and an independently verified readiness state. Docker AP addresses, Template Ingress hosts, and GitHub Agent-declared URLs all converge on this contract; a Template Entry does not — it is taken as declared on a host these endpoints already verified. The observer resolves the provider's actual address; Brain never reconstructs an address or infers WSS from HTTPS. Once verified, an endpoint that reaches an App Listening Port of one of the task's APs — an AP Public Address, or a Template Ingress host the AP observed — is named by that port's Port Display Name form; an Agent-declared URL keeps its declared label. _Avoid_: guessed URL, inferred socket address, source-specific public access card. ### Template Entry -A URL a Sealos Template declares in its header (`spec.entries`) for one of two roles, each a full URL rendered with the template's own `${{ }}` substitution: the Open Entry is what the Open control opens after the deployment, and the Share Entry is what the Deployment Task Success Record's share strip shares. When a template declares no Open Entry, its Sealos App CR url (`spec.data.url`) stands in; with neither, the Default Open Port's automatic rule decides. The Share Entry has one fallback only, the Open URL — never the App CR url, which may embed a secret. Each is a Deployment Access Endpoint with a declared URL, probed verbatim (query string kept, matched by full URL) and admitted to the record only once verified; it is optional evidence, never a completion gate, and an unverified one simply yields to its fallback. An entry whose host no Ingress of the deployment serves is dropped. The Open Entry also presets the AP's Default Open Port (ADR 0081). +A URL a Sealos Template declares in its header (`spec.entries`) for one of two roles, each a full URL rendered with the template's own `${{ }}` substitution: the Open Entry is what the Open control opens after the deployment, and the Share Entry is what the Deployment Task Success Record's share strip shares. When a template declares no Open Entry, its Sealos App CR url (`spec.data.url`) stands in; with neither, the Default Open Port's automatic rule decides. The Share Entry has one fallback only, the Open URL — never the App CR url, which may embed a secret. An entry is kept only when an Ingress of the same deployment serves its host, and that host is a Deployment Access Endpoint whose probe already gates the record — so a Template Entry is not itself probed and is taken as declared, query string and all: whether the application answers on its path is not routing health, exactly as Public Address Health has it. The Open Entry is the record's first entry, headed by the App Listening Port it reaches when an AP observed that address; the Share Entry is the record's share address and is never listed as an entry. The Open Entry also presets the AP's Default Open Port (ADR 0081). _Avoid_: primary entry, launch link, main domain, share project, share deployment, App CR link (as the product term), entry point (for the field). @@ -386,7 +386,7 @@ A Deployment Task Timeline section for one Deployment Result Resource, presentin ### Deployment Task Success Record -The conclusion a Deployment Task Timeline appends once Deployment Result Readiness is reached and every required access endpoint has passed its protocol probe. It carries only facts the deployment declared — product name, verified HTTP or WebSocket entries, first-use steps — so the Timeline never presents an address or instruction the runner cannot evidence. Each entry is headed the way a Public Address is shown everywhere else: the Port Display Name form of the App Listening Port it reaches (`game · 5200`, or `5200` alone for an unnamed port), as it stood at verification time; an entry no App Listening Port can be found for keeps the name its source declared. Two entries reaching the same port carry the same heading; the record stays a flat list. A record with a single entry heads it with nothing, whatever heading its source would have given it, as the node draws a lone Public Address. HTTP(S) entries can be opened and copied; WS(S) entries are copied. Its Open control opens the template's verified Open Entry when there is one, else the Default Open Port through its best Public Address, as decided when the record was written — the record is a snapshot, so a later rename or Default Open Port change does not rewrite it. A verified deployment with no endpoint uses the neutral `Deployment completed` headline, while `You can start using it` is reserved for a verified actionable entry. A task with no required Deployment Result Resource publishes no record and keeps reporting progress. It is part of the task-owned timeline snapshot, not a Chat message or a toast, and its Timeline revision doubles as its identity. Its share address — the template's verified Share Entry, else its Open URL, snapshotted as `shareUrl` (a record written before the field shares its primary HTTP(S) entry) — may be shared: copied, shown as a QR code, or posted to a social network as the product's own public address; this shares nothing of Brain and is not Public Project Preview Sharing, which no longer exists. Its first-use steps appear under the user-facing heading `Next steps`, and only when the deployment declared them; a record without declared steps shows no heading. A record written for a template deployment also snapshots the template's catalog name as its product id and the template's declared categories (`game`, `ai`, …) as they stood when the task was created, so the share copy can speak to a game or an AI app without asking the catalog again; other sources declare neither. +The conclusion a Deployment Task Timeline appends once Deployment Result Readiness is reached and every required access endpoint has passed its protocol probe. It carries only facts the deployment declared — product name, verified HTTP or WebSocket entries, the template's Open Entry on a host those entries verified, first-use steps — so the Timeline never presents an address or instruction the runner cannot evidence. Each entry is headed the way a Public Address is shown everywhere else: the Port Display Name form of the App Listening Port it reaches (`game · 5200`, or `5200` alone for an unnamed port), as it stood at verification time; an entry no App Listening Port can be found for keeps the name its source declared. Two entries reaching the same port carry the same heading; the record stays a flat list. A record with a single entry heads it with nothing, whatever heading its source would have given it, as the node draws a lone Public Address. HTTP(S) entries can be opened and copied; WS(S) entries are copied. Its Open control opens the template's Open Entry when there is one, else the Default Open Port through its best Public Address, as decided when the record was written — the record is a snapshot, so a later rename or Default Open Port change does not rewrite it. A verified deployment with no endpoint uses the neutral `Deployment completed` headline, while `You can start using it` is reserved for a verified actionable entry. A task with no required Deployment Result Resource publishes no record and keeps reporting progress. It is part of the task-owned timeline snapshot, not a Chat message or a toast, and its Timeline revision doubles as its identity. Its share address — the template's Share Entry, else its Open URL, snapshotted as `shareUrl` (a record written before the field shares its primary HTTP(S) entry) — may be shared: copied, shown as a QR code, or posted to a social network as the product's own public address; this shares nothing of Brain and is not Public Project Preview Sharing, which no longer exists. Its first-use steps appear under the user-facing heading `Next steps`, and only when the deployment declared them; a record without declared steps shows no heading. A record written for a template deployment also snapshots the template's catalog name as its product id and the template's declared categories (`game`, `ai`, …) as they stood when the task was created, so the share copy can speak to a game or an AI app without asking the catalog again; other sources declare neither. _Avoid_: success toast, deploy done banner, completion notification, "Public address" as an entry heading, share project, share deployment. diff --git a/apps/ui/src/features/deploy/task/direct-timeline.test.ts b/apps/ui/src/features/deploy/task/direct-timeline.test.ts index 715fcd0b1..0982cf6d8 100644 --- a/apps/ui/src/features/deploy/task/direct-timeline.test.ts +++ b/apps/ui/src/features/deploy/task/direct-timeline.test.ts @@ -5,7 +5,6 @@ import { applyApReadinessToResultCard, apResultResourceCardsFromArtifactSummary, resultResourceCardsFromArtifactSummary, - templateEntryAccessEndpointCards, } from "./direct-timeline"; test("direct deployment timeline creates no AP card before AP result evidence is known", () => { @@ -426,103 +425,3 @@ test("direct deployment timeline applies AP workload readiness to the AP card", } ); }); - -test("Template Entries become optional declared endpoint cards, de-duplicated by full URL", () => { - const host = "eagler-demo.example.sealos.run"; - const share = `https://${host}/?server=wss://${host}/`; - const ingressCards = resultResourceCardsFromArtifactSummary({ - resourceYamls: [ - `apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: eaglercraft-admin - namespace: ns-demo -spec: - tls: - - hosts: - - ${host} - rules: - - host: ${host} - http: - paths: - - path: /admin - pathType: Prefix - backend: - service: - name: eaglercraft - port: - number: 5201 -`, - ], - }); - assert.deepEqual( - ingressCards.map( - (card) => card.resultRef.kind === "AccessEndpoint" && card.resultRef.url - ), - [`https://${host}/admin`] - ); - - const cards = templateEntryAccessEndpointCards({ - entries: { open: `https://${host}/admin`, share }, - existingCards: ingressCards, - namespace: "ns-demo", - }); - // The Open URL is already observed through the Ingress; only Share is new. - assert.deepEqual(cards, [ - { - events: [], - id: "AccessEndpoint:ns-demo:template-entry:share", - required: false, - resultRef: { - id: "template-entry:share", - kind: "AccessEndpoint", - label: "Share address", - namespace: "ns-demo", - observer: { entry: "share", kind: "template-entry" }, - protocol: "https", - url: share, - }, - status: "creating", - title: "Share address", - }, - ]); -}); - -test("a Template Entry Open URL no card observes gets its own card, once even when Share repeats it", () => { - const url = "https://demo.example.sealos.run/app"; - const cards = templateEntryAccessEndpointCards({ - entries: { open: url, share: url }, - existingCards: [], - namespace: "ns-demo", - }); - assert.deepEqual( - cards.map((card) => [ - card.id, - card.resultRef.kind === "AccessEndpoint" && card.resultRef.label, - card.required, - ]), - [["AccessEndpoint:ns-demo:template-entry:open", "Web address", false]] - ); - assert.deepEqual( - templateEntryAccessEndpointCards({ - entries: { open: "wss://demo.example.sealos.run/" }, - existingCards: [], - namespace: "ns-demo", - }).map( - (card) => - card.resultRef.kind === "AccessEndpoint" && [ - card.resultRef.label, - card.resultRef.protocol, - ] - ), - [["WebSocket address", "wss"]] - ); - assert.deepEqual( - templateEntryAccessEndpointCards({ - entries: {}, - existingCards: [], - namespace: "ns-demo", - }), - [] - ); -}); diff --git a/apps/ui/src/features/deploy/task/direct-timeline.ts b/apps/ui/src/features/deploy/task/direct-timeline.ts index e6052fc24..43ffaa373 100644 --- a/apps/ui/src/features/deploy/task/direct-timeline.ts +++ b/apps/ui/src/features/deploy/task/direct-timeline.ts @@ -5,9 +5,7 @@ import type { DeployTaskArtifactSummary } from "./schema"; import { type DeploymentResultResourceCard, type DeploymentResultResourceRef, - type DeploymentTemplateEntryUrls, deploymentResultResourceCardId, - type TemplateEntryRole, } from "./timeline"; const TEMPLATE_WORKLOAD_KIND_BY_NORMALIZED = new Map([ @@ -144,80 +142,6 @@ function ingressAccessEndpointCard(input: { }); } -const ACCESS_ENDPOINT_PROTOCOLS = new Set(["http", "https", "ws", "wss"]); - -function accessEndpointProtocol( - url: string -): "http" | "https" | "ws" | "wss" | null { - try { - const protocol = new URL(url).protocol.slice(0, -1); - return ACCESS_ENDPOINT_PROTOCOLS.has(protocol) - ? (protocol as "http" | "https" | "ws" | "wss") - : null; - } catch { - return null; - } -} - -function templateEntryLabel( - role: TemplateEntryRole, - protocol: "http" | "https" | "ws" | "wss" -): string { - if (role === "share") { - return "Share address"; - } - return protocol === "ws" || protocol === "wss" - ? "WebSocket address" - : "Web address"; -} - -/** - * Template Entries (ADR 0081) as Deployment Access Endpoint cards: one per - * declared URL, probed verbatim like an Agent-declared URL, query string - * included. A URL some other card already observes (an Ingress root, say) is - * not doubled — the record de-duplicates by full URL and the runner still - * knows which URL is the Open one. The cards are optional: a declared entry - * that fails its probe stays out of the record rather than failing a - * deployment whose Ingress-derived entries already gate usability. - */ -export function templateEntryAccessEndpointCards(input: { - entries: DeploymentTemplateEntryUrls; - existingCards: readonly DeploymentResultResourceCard[]; - namespace: string; -}): DeploymentResultResourceCard[] { - const seen = new Set( - input.existingCards.flatMap((card) => - card.resultRef.kind === "AccessEndpoint" && card.resultRef.url != null - ? [card.resultRef.url] - : [] - ) - ); - const roles: TemplateEntryRole[] = ["open", "share"]; - return roles.flatMap((role) => { - const url = input.entries[role]; - const protocol = url == null ? null : accessEndpointProtocol(url); - if (url == null || protocol == null || seen.has(url)) { - return []; - } - seen.add(url); - const label = templateEntryLabel(role, protocol); - return [ - resultCard( - { - id: `template-entry:${role}`, - kind: "AccessEndpoint", - label, - namespace: input.namespace, - observer: { entry: role, kind: "template-entry" }, - protocol, - url, - }, - { required: false } - ), - ]; - }); -} - function templateIngressIdentity( summary: DeployTaskArtifactSummary, doc: Record, diff --git a/apps/ui/src/features/deploy/task/result-readiness.test.ts b/apps/ui/src/features/deploy/task/result-readiness.test.ts index 326384f45..fd5a68747 100644 --- a/apps/ui/src/features/deploy/task/result-readiness.test.ts +++ b/apps/ui/src/features/deploy/task/result-readiness.test.ts @@ -552,133 +552,3 @@ it("keeps the Ingress label when no AP of the task knows the host", async () => expect(observed.running).toBe(true); expect(observed.card.resultRef).toMatchObject({ label: "Web address" }); }); - -it("probes a Template Entry share address verbatim, query string included, and keeps its own label", async () => { - globalThis.fetch = probeFetch as unknown as typeof fetch; - fetcher.mockClear(); - const host = "eagler-demo.example.sealos.run"; - const share = `https://${host}/?server=wss://${host}/`; - - const observed = await observeDeploymentResultCardReadiness({ - allowedDomain: "example.sealos.run", - apCandidates: [{ name: "eaglercraft", namespace: "ns-demo" }], - card: { - events: [], - id: "AccessEndpoint:ns-demo:template-entry:share", - required: false, - resultRef: { - id: "template-entry:share", - kind: "AccessEndpoint", - label: "Share address", - namespace: "ns-demo", - observer: { entry: "share", kind: "template-entry" }, - protocol: "https", - url: share, - }, - status: "creating", - title: "Share address", - }, - kubeconfig: "kubeconfig", - }); - - expect(observed.status).toBe("running"); - expect(probeFetch).toHaveBeenCalledTimes(1); - expect(String(probeFetch.mock.calls[0]?.[0])).toBe(share); - // A Share entry is never renamed after a port; the AP view is not even read. - expect(fetcher).not.toHaveBeenCalled(); - expect(observed.card.resultRef).toMatchObject({ - label: "Share address", - url: share, - }); -}); - -it("names a Template Entry Open address after the App Listening Port it reaches, without root discovery", async () => { - globalThis.fetch = probeFetch as unknown as typeof fetch; - fetcher.mockResolvedValueOnce({ - status: { - network: { - appListeningPorts: [ - { displayName: "game", port: 5200 }, - { displayName: "admin", port: 5201 }, - ], - publicAddresses: [ - { - host: "eagler-demo.example.sealos.run", - id: "observed-1", - port: 5200, - status: "accessible", - type: "observed", - url: "wss://eagler-demo.example.sealos.run/", - }, - { - host: "eagler-demo.example.sealos.run", - id: "observed-2", - port: 5201, - status: "accessible", - type: "observed", - url: "https://eagler-demo.example.sealos.run/admin", - }, - ], - }, - }, - }); - - const observed = await observeDeploymentResultCardReadiness({ - allowedDomain: "example.sealos.run", - apCandidates: [{ name: "eaglercraft", namespace: "ns-demo" }], - card: { - events: [], - id: "AccessEndpoint:ns-demo:template-entry:open", - required: false, - resultRef: { - id: "template-entry:open", - kind: "AccessEndpoint", - label: "Web address", - namespace: "ns-demo", - observer: { entry: "open", kind: "template-entry" }, - protocol: "https", - url: "https://eagler-demo.example.sealos.run/admin", - }, - status: "creating", - title: "Web address", - }, - kubeconfig: "kubeconfig", - }); - - expect(observed.status).toBe("running"); - expect(observed.card.resultRef).toMatchObject({ - label: "admin · 5201", - url: "https://eagler-demo.example.sealos.run/admin", - }); -}); - -it("never verifies a declared Template Entry through a root it did not declare", async () => { - probeFetch.mockImplementationOnce( - async () => new Response(null, { status: 404 }) - ); - globalThis.fetch = probeFetch as unknown as typeof fetch; - - const observed = await observeDeploymentResultCardReadiness({ - allowedDomain: "example.sealos.run", - card: { - events: [], - id: "AccessEndpoint:ns-demo:template-entry:open", - required: false, - resultRef: { - id: "template-entry:open", - kind: "AccessEndpoint", - label: "Web address", - namespace: "ns-demo", - observer: { entry: "open", kind: "template-entry" }, - protocol: "https", - url: "https://eagler-demo.example.sealos.run/admin", - }, - status: "creating", - title: "Web address", - }, - kubeconfig: "kubeconfig", - }); - - expect(observed.status).toBe("unknown"); - expect(probeFetch).toHaveBeenCalledTimes(1); -}); diff --git a/apps/ui/src/features/deploy/task/result-readiness.ts b/apps/ui/src/features/deploy/task/result-readiness.ts index 7f78e8dea..be489ccf3 100644 --- a/apps/ui/src/features/deploy/task/result-readiness.ts +++ b/apps/ui/src/features/deploy/task/result-readiness.ts @@ -357,15 +357,15 @@ export function deploymentResultApCandidates( } /** - * The label an Ingress-observed endpoint carries once verified: the Port - * Display Name form of the App Listening Port behind that endpoint, read - * from the first candidate AP whose Product View observed its host. The - * endpoint is matched by its whole URL, since one host may expose several - * ports by protocol and path. A host no AP claims — a template with no - * AP-like workload, or a view that cannot be read — keeps the label the - * Ingress gave it; naming is never invented here. + * The label an Ingress-observed endpoint carries once verified — and the one + * a template's Open Entry is headed by in the Success Record: the Port + * Display Name form of the App Listening Port behind that URL, read from + * the first candidate AP whose Product View observed its host. The URL is + * matched whole, since one host may expose several ports by protocol and + * path. A host no AP claims — a template with no AP-like workload, or a view + * that cannot be read — yields nothing; naming is never invented here. */ -async function ingressAccessEndpointPortLabel(input: { +export async function accessEndpointPortLabelForUrl(input: { candidates: readonly DeploymentResultApCandidate[]; kubeconfig: string; signal?: AbortSignal; @@ -485,19 +485,13 @@ async function accessEndpointReadiness( signal: input.signal, }); publicUrl = resolved.url; - // A Template Entry's Open URL is named like an Ingress host once verified: - // by the App Listening Port it reaches. A Share entry keeps its own label. - const namedByPort = - resultRef.observer.kind === "ingress" || - (resultRef.observer.kind === "template-entry" && - resultRef.observer.entry === "open"); if ( portLabel === undefined && - namedByPort && + resultRef.observer.kind === "ingress" && input.apCandidates != null && input.apCandidates.length > 0 ) { - portLabel = await ingressAccessEndpointPortLabel({ + portLabel = await accessEndpointPortLabelForUrl({ candidates: input.apCandidates, kubeconfig: input.kubeconfig, signal: input.signal, diff --git a/apps/ui/src/features/deploy/task/runner.ts b/apps/ui/src/features/deploy/task/runner.ts index 427e339e6..ce7289090 100644 --- a/apps/ui/src/features/deploy/task/runner.ts +++ b/apps/ui/src/features/deploy/task/runner.ts @@ -87,10 +87,7 @@ import { } from "./billing-failure-judgment"; import { buildRuntimeContract } from "./build-runtime-contract"; import { resolveGithubTokenForDeploymentTask } from "./credential-binding"; -import { - resultResourceCardsFromArtifactSummary, - templateEntryAccessEndpointCards, -} from "./direct-timeline"; +import { resultResourceCardsFromArtifactSummary } from "./direct-timeline"; import { isDeployTaskAbortError } from "./engine/errors"; import type { DeployTaskHandle } from "./engine/handle"; import { @@ -137,6 +134,7 @@ import { attachManagedDeploymentTimelineSuccess } from "./managed-timeline"; import { deployOutputProgressSummary } from "./output-progress"; import { deploymentTaskSourceProduct } from "./projection"; import { + accessEndpointPortLabelForUrl, type DeploymentResultApCandidate, deploymentResultApCandidates, isResultReadinessTerminalError, @@ -2678,20 +2676,10 @@ async function completeTaskWithArtifact(input: { } } - const observedCards = [ + const resultCards = [ ...resultResourceCardsFromArtifactSummary(persistedSummary), ...templatePublicAccessCards, ]; - const resultCards = [ - ...observedCards, - ...(templateEntries === undefined - ? [] - : templateEntryAccessEndpointCards({ - entries: templateEntries, - existingCards: observedCards, - namespace: input.task.namespace, - })), - ]; for (const card of resultCards) { await upsertResultTimelineCard({ card, @@ -2735,19 +2723,34 @@ async function completeTaskWithArtifact(input: { // record is attached and the Timeline keeps reporting progress (issue #160). // Neither an entry address nor first-use guidance is declared here, so both // stay absent rather than being invented from a host or a port. - // The record's Open control opens the Default Open Port through its best - // Public Address, decided once here from the AP as it stands at - // verification time (CONTEXT.md: Default Open Port). + // The record's Open control opens the template's Open Entry when it declared + // one, else the Default Open Port through its best Public Address, decided + // once here from the AP as it stands at verification time (CONTEXT.md: + // Default Open Port, Template Entry). A Template Entry is not probed: its + // host is an Ingress host of this deployment, already verified above, and + // an application's own response on a path is not routing health. The Open + // Entry is headed by the App Listening Port it reaches, as an Ingress host + // is, when an AP of the task observed that address. + const apCandidates = deploymentResultApCandidates(resultCards); const primaryEntryUrl = await resolveDeploymentSuccessOpenUrl({ - candidates: deploymentResultApCandidates(resultCards), + candidates: apCandidates, kubeconfig: input.kubeconfig, }); + const templateOpenEntryLabel = + templateEntries?.open === undefined + ? undefined + : await accessEndpointPortLabelForUrl({ + candidates: apCandidates, + kubeconfig: input.kubeconfig, + url: templateEntries.open, + }); await updateDeployTaskTimeline(input.task.id, { update: (timeline) => { const success = deploymentTaskSuccessFromTimeline(timeline, { primaryEntryUrl, ...deploymentTaskSourceProduct(input.task.source), templateEntries, + templateOpenEntryLabel, }); return success == null ? timeline diff --git a/apps/ui/src/features/deploy/task/timeline.test.ts b/apps/ui/src/features/deploy/task/timeline.test.ts index 22b15309f..c5c6a242e 100644 --- a/apps/ui/src/features/deploy/task/timeline.test.ts +++ b/apps/ui/src/features/deploy/task/timeline.test.ts @@ -1249,7 +1249,7 @@ function endpointCard(input: { }; } -function eaglercraftTimeline(shareStatus: "creating" | "running") { +function eaglercraftTimeline(input: { adminObserved: boolean }) { let timeline = timelineFrame({ "AP:default:eaglercraft": "running", "PublicAccess:default:eaglercraft:lobby": "running", @@ -1264,24 +1264,19 @@ function eaglercraftTimeline(shareStatus: "creating" | "running") { status: "running", url: `wss://${EAGLER_HOST}/`, }), - endpointCard({ - id: "ingress:eaglercraft-admin:https:admin", - label: "admin · 5201", - observer: { kind: "ingress", name: "eaglercraft-admin" }, - protocol: "https", - required: true, - status: "running", - url: EAGLER_OPEN, - }), - endpointCard({ - id: "template-entry:share", - label: "Share address", - observer: { entry: "share", kind: "template-entry" }, - protocol: "https", - required: false, - status: shareStatus, - url: EAGLER_SHARE, - }), + ...(input.adminObserved + ? [ + endpointCard({ + id: "ingress:eaglercraft-admin:https:admin", + label: "admin · 5201", + observer: { kind: "ingress", name: "eaglercraft-admin" }, + protocol: "https", + required: true, + status: "running", + url: EAGLER_OPEN, + }), + ] + : []), ]; for (const card of cards) { timeline = upsertResultResourceCard(timeline, { @@ -1293,51 +1288,69 @@ function eaglercraftTimeline(shareStatus: "creating" | "running") { return timeline; } -test("a verified Template Entry puts the Open URL first and snapshots the share address verbatim", () => { +test("a template's Open Entry leads the record and its Share Entry is the share address, not an entry", () => { const success = deploymentTaskSuccessFromTimeline( - eaglercraftTimeline("running"), + eaglercraftTimeline({ adminObserved: true }), { // The automatic rule would open the game root; the template's Open wins. primaryEntryUrl: `https://${EAGLER_HOST}/`, productName: "EaglerCraft Server", templateEntries: { open: EAGLER_OPEN, share: EAGLER_SHARE }, + templateOpenEntryLabel: "admin · 5201", } ); - assert.deepEqual( - success?.entries?.map((entry) => entry.url), - [EAGLER_OPEN, `wss://${EAGLER_HOST}/`, EAGLER_SHARE] - ); + // The Ingress card already lists the Open URL: one entry, the card's own. + assert.deepEqual(success?.entries, [ + { label: "admin · 5201", protocol: "https", url: EAGLER_OPEN }, + { label: "game · 5200", protocol: "wss", url: `wss://${EAGLER_HOST}/` }, + ]); assert.equal(success?.shareUrl, EAGLER_SHARE); - // Optional entry evidence never inflates the verification count. + // Declared entries are not probes; they never touch the verification count. assert.deepEqual(success?.verification, { passed: 4, total: 4 }); }); -test("an unverified Template Entry stays out of the record and the share falls back to the Open URL", () => { +test("an Open Entry no card lists is added as declared, headed by the port it reaches", () => { const success = deploymentTaskSuccessFromTimeline( - eaglercraftTimeline("creating"), + eaglercraftTimeline({ adminObserved: false }), { - primaryEntryUrl: `https://${EAGLER_HOST}/`, + primaryEntryUrl: `wss://${EAGLER_HOST}/`, productName: "EaglerCraft Server", - templateEntries: { open: EAGLER_OPEN, share: EAGLER_SHARE }, + templateEntries: { open: EAGLER_OPEN }, + templateOpenEntryLabel: "admin · 5201", } ); - assert.deepEqual( - success?.entries?.map((entry) => entry.url), - [EAGLER_OPEN, `wss://${EAGLER_HOST}/`] - ); + assert.deepEqual(success?.entries, [ + { label: "admin · 5201", protocol: "https", url: EAGLER_OPEN }, + { label: "game · 5200", protocol: "wss", url: `wss://${EAGLER_HOST}/` }, + ]); + // No Share Entry: the Open URL is what the strip shares. assert.equal(success?.shareUrl, EAGLER_OPEN); - // A declared Open URL the probe never confirmed leaves the automatic rule in charge. - const unverifiedOpen = deploymentTaskSuccessFromTimeline( - eaglercraftTimeline("running"), + // No AP observed the address: the entry is headed by nothing, not invented. + const unnamed = deploymentTaskSuccessFromTimeline( + eaglercraftTimeline({ adminObserved: false }), { - primaryEntryUrl: `wss://${EAGLER_HOST}/`, + primaryEntryUrl: null, + productName: null, + templateEntries: { open: EAGLER_OPEN }, + } + ); + assert.deepEqual(unnamed?.entries?.[0], { + protocol: "https", + url: EAGLER_OPEN, + }); +}); + +test("a WebSocket Share Entry is never the share address; the Open URL is shared instead", () => { + const success = deploymentTaskSuccessFromTimeline( + eaglercraftTimeline({ adminObserved: true }), + { + primaryEntryUrl: `https://${EAGLER_HOST}/`, productName: null, - templateEntries: { open: `https://${EAGLER_HOST}/elsewhere` }, + templateEntries: { open: EAGLER_OPEN, share: `wss://${EAGLER_HOST}/` }, } ); - assert.equal(unverifiedOpen?.entries?.[0]?.url, `wss://${EAGLER_HOST}/`); - assert.equal(unverifiedOpen?.shareUrl, EAGLER_OPEN); + assert.equal(success?.shareUrl, EAGLER_OPEN); }); test("the share address is a snapshot the sanitizer keeps for HTTP(S) only, and old records share their primary entry", () => { diff --git a/apps/ui/src/features/deploy/task/timeline.ts b/apps/ui/src/features/deploy/task/timeline.ts index 2bd5f2955..53cc6c9a5 100644 --- a/apps/ui/src/features/deploy/task/timeline.ts +++ b/apps/ui/src/features/deploy/task/timeline.ts @@ -26,13 +26,13 @@ export type DeploymentAccessEndpointProtocol = "http" | "https" | "ws" | "wss"; export type DeploymentAccessEndpointObserver = | { addressId: string; apName: string; kind: "ap-public-address" } | { kind: "declared" } - | { kind: "ingress"; name: string } - /** A Template Entry (ADR 0081): the template's declared Open or Share URL. */ - | { entry: TemplateEntryRole; kind: "template-entry" }; + | { kind: "ingress"; name: string }; -export type TemplateEntryRole = "open" | "share"; - -/** The Open and Share URLs a template deployment declared (ADR 0081). */ +/** + * The Open and Share URLs a template deployment declared (ADR 0081). Their + * hosts are Ingress hosts of the same deployment — that gate is applied + * where the entries are resolved — so the record takes them as declared. + */ export interface DeploymentTemplateEntryUrls { open?: string; share?: string; @@ -189,7 +189,7 @@ export interface DeploymentTaskSuccessSnapshot { revision: number; /** * The HTTP(S) address the share strip shares (ADR 0081): the template's - * verified Share entry, else the Open URL as it stood when the record was + * declared Share Entry, else the Open URL as it stood when the record was * written. Absent on records written before the field existed, which * share their primary entry. */ @@ -1017,12 +1017,20 @@ export function prioritizeSuccessEntries( return [primary, ...entries.filter((_, position) => position !== index)]; } -/** A Template Entry card is optional evidence: verified, it enters the record. */ -function isTemplateEntryCard(card: DeploymentResultResourceCard): boolean { - return ( - card.resultRef.kind === "AccessEndpoint" && - card.resultRef.observer.kind === "template-entry" - ); +/** + * The template's Open Entry as a record entry: the declared URL, headed by + * the App Listening Port it reaches when one is known. A URL an Ingress card + * already lists keeps that card's entry instead (de-duplicated by full URL). + */ +function templateOpenEntry( + url: string | null | undefined, + label: string | null | undefined +): DeploymentTaskSuccessEntry | null { + const protocol = url == null ? null : accessProtocol(url); + if (url == null || protocol == null) { + return null; + } + return { ...(label == null ? {} : { label }), protocol, url }; } /** @@ -1044,26 +1052,28 @@ export function deploymentTaskSuccessFromTimeline( /** The Default Open Port's best Public Address, when the task has one. */ primaryEntryUrl?: string | null; /** - * The template's declared entries (ADR 0081). A verified Open entry wins - * over `primaryEntryUrl`; a verified Share entry becomes the record's - * share address, else the Open URL is shared. An unverified entry is - * ignored: the record carries only addresses the probe confirmed. + * The template's declared entries (ADR 0081), taken as declared: their + * hosts are Ingress hosts the required probes already verified. The Open + * Entry is the record's first entry and wins over `primaryEntryUrl`; the + * Share Entry becomes the record's share address and is not listed as an + * entry, else the Open URL is shared. */ templateEntries?: DeploymentTemplateEntryUrls | null; + /** + * The Port Display Name form of the App Listening Port the Open Entry + * reaches, when an AP of the task observed it; absent, an Open Entry no + * verified card already lists is headed by nothing. + */ + templateOpenEntryLabel?: string | null; } ): DeploymentTaskSuccessAttachment | null { if (!deploymentTimelineResultReadinessReached(timeline)) { return null; } - const cards = timeline.steps.flatMap((step) => step.resultCards ?? []); - const runningCards = cards.filter( - (card) => card.required && card.status === "running" - ); - const verifiedEndpointCards = cards.filter( - (card) => - card.status === "running" && (card.required || isTemplateEntryCard(card)) - ); - const endpointEntries = verifiedEndpointCards.flatMap((card) => { + const runningCards = timeline.steps + .flatMap((step) => step.resultCards ?? []) + .filter((card) => card.required && card.status === "running"); + const endpointEntries = runningCards.flatMap((card) => { switch (card.resultRef.kind) { case "AccessEndpoint": return card.resultRef.url == null @@ -1081,26 +1091,24 @@ export function deploymentTaskSuccessFromTimeline( return []; } }); - const verifiedUrls = new Set(endpointEntries.map((entry) => entry.url)); - const declaredOpen = input.templateEntries?.open; - const openUrl = - declaredOpen != null && verifiedUrls.has(declaredOpen) - ? declaredOpen - : input.primaryEntryUrl; + const openEntry = templateOpenEntry( + input.templateEntries?.open, + input.templateOpenEntryLabel + ); + const declaredEntries = + openEntry == null ? endpointEntries : [openEntry, ...endpointEntries]; const uniqueEntries = prioritizeSuccessEntries( - endpointEntries.filter( + declaredEntries.filter( (entry, index) => - endpointEntries.findIndex( + declaredEntries.findIndex( (candidate) => candidate.url === entry.url ) === index ), - openUrl + openEntry?.url ?? input.primaryEntryUrl ); const declaredShare = input.templateEntries?.share; const shareUrl = - declaredShare != null && - verifiedUrls.has(declaredShare) && - isHttpEntryUrl(declaredShare) + declaredShare != null && isHttpEntryUrl(declaredShare) ? declaredShare : deploymentTaskSuccessShareUrl({ entries: uniqueEntries }); const success = deploymentTaskSuccessFromResultReadiness({ diff --git a/apps/ui/src/features/deploy/template-entries.ts b/apps/ui/src/features/deploy/template-entries.ts index 822ac541e..b87aa3044 100644 --- a/apps/ui/src/features/deploy/template-entries.ts +++ b/apps/ui/src/features/deploy/template-entries.ts @@ -81,7 +81,7 @@ export function templateDeclaredEntries( /** * A declared entry as a usable absolute URL, or nothing. The query string is * kept verbatim (a share link may carry one); credentials and fragments are - * rejected because a fragment never reaches the server and cannot be probed. + * rejected because a fragment never reaches the server. */ export function templateEntryUrl(value: unknown): string | undefined { const raw = stringValue(value); diff --git a/docs/adr/0079-model-public-entry-points-as-deployment-access-endpoints.md b/docs/adr/0079-model-public-entry-points-as-deployment-access-endpoints.md index 8b5bff736..df614bb59 100644 --- a/docs/adr/0079-model-public-entry-points-as-deployment-access-endpoints.md +++ b/docs/adr/0079-model-public-entry-points-as-deployment-access-endpoints.md @@ -2,12 +2,12 @@ ## Status -Accepted; amended in place (the entry-path rule, below) and extended by -ADR-0081, which adds a template-declared observer kind (Template Entries) to -the declared-URL family. Template Entries are probed under this ADR's -contract but are optional evidence: they do not gate completion the way an -Agent-declared endpoint does, and CONTEXT.md's former "no Template-Instance- -level open link" no longer holds. +Accepted; amended in place (the entry-path rule, below). ADR-0081 adds +Template Entries beside this contract, not inside it: a Template Entry is +not a Deployment Access Endpoint and is not probed — its host is an Ingress +host whose required endpoint this ADR already verifies — and it borrows only +the naming rule for the port an entry reaches. CONTEXT.md's former "no +Template-Instance-level open link" no longer holds. ## Context diff --git a/docs/adr/0081-declare-template-entries-for-open-and-share.md b/docs/adr/0081-declare-template-entries-for-open-and-share.md index a0f1cce64..4d91a3c28 100644 --- a/docs/adr/0081-declare-template-entries-for-open-and-share.md +++ b/docs/adr/0081-declare-template-entries-for-open-and-share.md @@ -18,8 +18,9 @@ share — only the template author does. Two facts made the gap avoidable. 212 of the 256 catalog templates already apply a Sealos App CR (`app.sealos.io/v1` `App`) whose `spec.data.url` is the address the Sealos desktop launcher opens; Brain applied that object and never -read it. And ADR 0079 already has a contract for a declared, probe-verified -URL — the one GitHub Agent-declared endpoints use. +read it. And every Ingress host of a template deployment is already a +required Deployment Access Endpoint (ADR 0079) whose probe gates the Success +Record — so a declared URL on one of those hosts stands on verified ground. CONTEXT.md's Default Open Port entry said "there is no Project-level or Template-Instance-level open link". The product owner decided on 2026-09-08 @@ -51,17 +52,22 @@ CR URLs embed a secret (`#token=…`, `/invite/`) that must not be posted to a social network. It only ever equals `entries.share` or the resolved Open URL. -**Verification.** A Template Entry is a Deployment Access Endpoint with a -declared URL — ADR 0079's contract. It is probed like any HTTP or WebSocket -endpoint, verbatim: a share URL keeps its query string, the GET is on the -full URL, and endpoints are de-duplicated by full URL, so an Open entry that -names the address an Ingress card already observes adds no second card. Only -a verified entry enters the Success Record. Unlike an Agent-declared -endpoint, a Template Entry does not gate completion: the Ingress-derived -entries already gate usability, and a declared link that fails its probe is -left out of the record rather than failing a deployment that works. A -declared Open entry the probe did not confirm leaves the automatic rule in -charge; an unconfirmed Share entry leaves the Open URL shared. +**No probe.** A Template Entry is not a Deployment Access Endpoint and is not +probed. It is kept only when an Ingress of the same deployment serves its +host (the boundary below), and every such host is a required Deployment +Access Endpoint whose probe already gates the Success Record — so the entry's +host is verified before the record exists. What a probe of the full URL +would add is whether the application answers on that path or query string +at that moment, and that is not routing health: CONTEXT.md's Public Address +Health already says a workload 404 or 500 does not make an address +unhealthy. So the record takes the entries as declared, and a template's own +mistake in a path is the template author's to see and fix, not a reason to +fall back silently. The Open Entry is the record's first entry; when an +Ingress card already lists the same URL the card's entry stands (the record +de-duplicates by full URL), else the entry is added as declared, headed by +the App Listening Port it reaches when an AP of the task observed that +address and by nothing otherwise. The Share Entry is not listed as an entry +at all: it is the address the share strip shares, and only that. **Consistency with the AP.** When the Open URL — declared or from the App CR — can be matched to one App Listening Port of one of the deployment's APs, @@ -105,9 +111,19 @@ shares its primary HTTP(S) entry, as it always did. Share; and it cannot name a second, share-specific URL. - **Let Share fall back to the App CR URL** — rejected: `#token=` and `/invite/` URLs exist in the catalog today. -- **Make Template Entries gate completion like Agent-declared URLs** — - rejected for this field: 212 templates would gain a new required probe - overnight, and the Ingress-derived entries already prove usability. +- **Probe each entry as a Deployment Access Endpoint** — rejected, in two + strengths. As a completion gate, 212 templates would gain a new required + probe overnight for a URL nobody has checked. As optional evidence, a + failed probe would silently swap the declared Open for the automatic rule + while the Service annotation still named the declared port, and the user + would see two different Opens with no explanation. Either way the probe + verifies application response on a path, which the Public Address Health + definition already excludes from health; the host is verified by the + Ingress endpoint's own required probe. +- **List the Share Entry as a record entry** — rejected: the record's entries + are ways the user reaches the product, each headed by the port it reaches; + the share link is an address for other people and would be the one entry + with a label of its own, next to a near-duplicate of the Open URL. - **Name the port on the Ingress or a new CR** — rejected; ADR 0080 settled that port facts live on the Service. @@ -117,10 +133,11 @@ shares its primary HTTP(S) entry, as it always did. open link: a template presets its AP's Default Open Port through its entries, and the store, read path, and user override stay exactly ADR 0080's. The glossary gains Template Entry. -- ADR 0079's "declared URL" observer family gains a template-declared kind - that is named like an Ingress host once verified (Open) or keeps its - declared label (Share); its statement that a failed required probe - prevents completion is unchanged, since Template Entry cards are optional. +- ADR 0079's Deployment Access Endpoint contract is untouched: a Template + Entry is not an endpoint, adds no card to the Deployment Task Timeline, and + never counts toward the record's verification summary. The Open Entry + borrows only the endpoint naming rule (the Port Display Name of the App + Listening Port it reaches). - ADR 0080's note that "a template can preset" the Default Open Port now has a second writer: Brain itself, from the template's entries, at render or read-back time, and only where the template left the annotation empty. diff --git a/docs/adr/README.md b/docs/adr/README.md index c112b3853..de05b4de9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,7 +52,7 @@ One line per decision; the linked record is authoritative. When adding an ADR, t - [0078 — Conclude Deployment Tasks with an Evidence-Gated Success Record](0078-conclude-deployment-tasks-with-an-evidence-gated-success-record.md) *(extends ADR-0028 with the conclusion that timeline was missing; the failure counterpart is ADR-0042)* - [0079 — Model Public Entry Points as Deployment Access Endpoints](0079-model-public-entry-points-as-deployment-access-endpoints.md) *(revises ADR-0078's address and success-copy contract)* - [0080 — Port Display Names Live on the Service, Not the Ingress](0080-port-display-names-live-on-the-service.md) *(extends ADR-0066's annotation pattern to App Listening Ports; carves out a read-time fallback exception; complements ADR-0079's Deployment Access Endpoint labels)* -- [0081 — Declare Template Entries for Open and Share](0081-declare-template-entries-for-open-and-share.md) *(extends ADR-0079's declared-URL endpoints to template-declared entries; adds a Brain-side writer for ADR-0080's Default Open Port store; revises CONTEXT.md's "no Template-Instance-level open link")* +- [0081 — Declare Template Entries for Open and Share](0081-declare-template-entries-for-open-and-share.md) *(declares Open and Share beside ADR-0079's endpoints, unprobed on an Ingress host they verify; adds a Brain-side writer for ADR-0080's Default Open Port store; revises CONTEXT.md's "no Template-Instance-level open link")* ## Conventions From 59539e2e96ad2fa8de8a1f66b44390e3e57620af Mon Sep 17 00:00:00 2001 From: aimeritething Date: Wed, 9 Sep 2026 00:55:26 +0800 Subject: [PATCH 4/5] fix(deploy): address the PR #346 review of Template Entries - templateEntryOpenPort no longer falls through to a shorter Ingress rule when the longest-prefix rule's Service or port cannot be resolved; a shorter rule serves a different backend, and annotating it would make the AP's Open and the record's Open disagree. - The Success Record keeps the Ingress card's verified entry when it already lists the Open URL; the Open Entry is added only otherwise. - The share strip renders whenever the record has a shareUrl, no longer gated on an openable primary entry. - The provider read-back takes args as optional: an already-created instance passes none (args are never persisted, ADR 0037), and every entry that substitutes an input is dropped rather than rendered from the template default. - CONTEXT.md and ADR-0081 name AP Public Access Health by its glossary term, state that a ws(s):// Share Entry yields to the Open URL, and note the read-back-time annotation write for provider-applied templates. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011SQCgDEo6URtdrVtPEoBvv --- CONTEXT.md | 4 +- .../deployment-task-success-section.tsx | 2 +- apps/ui/src/features/deploy/task/runner.ts | 11 ++-- .../task/template-provider-entries.test.ts | 15 ++++- .../deploy/task/template-provider-entries.ts | 47 +++++++++++++- .../src/features/deploy/task/timeline.test.ts | 15 +++++ apps/ui/src/features/deploy/task/timeline.ts | 7 ++- .../features/deploy/template-entries.test.ts | 63 +++++++++++++++++++ .../src/features/deploy/template-entries.ts | 12 +++- ...are-template-entries-for-open-and-share.md | 14 +++-- 10 files changed, 170 insertions(+), 20 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 6112841ce..a18bcc0e3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -80,7 +80,7 @@ _Avoid_: Public Address name, domain name label, port alias. ### Default Open Port -The App Listening Port that the Open control opens — on the AP Public Access Node header and in the pane header of every AP-owned Settings View — through its best Public Address: an accessible Custom Domain, else an accessible Platform Address; with neither, Open is shown disabled, with the reason. The node's control names the port ("Open "), its only marker there; the pane control reads just "Open" and names the port only on hover, since the App Listening Ports card beside it already shows the choice. A stored choice lives only on the AP's Service (annotation `brain.io/default-open-port`, next to Port Display Names), so a template can preset it; users set or clear it from the port's row in the App Listening Ports card ("Open by default"), and clearing returns to the automatic rule: the first App Listening Port, in declaration order, whose HTTP Public Address enters at the root; with none at the root, the first that has any HTTP Public Address — so a backend port routed under `/api` yields to the page port however the template ordered them. A stored port that has no HTTP Public Address is ignored, not surfaced. Ports reached only by WS/WSS Public Addresses are never chosen. Owned by one AP. A template presets it through its Template Entries: at render time Brain follows the Open Entry — the declared one, else the template's Sealos App CR url — through the Ingress rule whose host and longest-prefix path serve it to the Service port behind it, and writes the annotation there unless the template already set it, so the node's Open and the Deployment Task Success Record's Open agree; no match writes nothing (ADR 0081). +The App Listening Port that the Open control opens — on the AP Public Access Node header and in the pane header of every AP-owned Settings View — through its best Public Address: an accessible Custom Domain, else an accessible Platform Address; with neither, Open is shown disabled, with the reason. The node's control names the port ("Open "), its only marker there; the pane control reads just "Open" and names the port only on hover, since the App Listening Ports card beside it already shows the choice. A stored choice lives only on the AP's Service (annotation `brain.io/default-open-port`, next to Port Display Names), so a template can preset it; users set or clear it from the port's row in the App Listening Ports card ("Open by default"), and clearing returns to the automatic rule: the first App Listening Port, in declaration order, whose HTTP Public Address enters at the root; with none at the root, the first that has any HTTP Public Address — so a backend port routed under `/api` yields to the page port however the template ordered them. A stored port that has no HTTP Public Address is ignored, not surfaced. Ports reached only by WS/WSS Public Addresses are never chosen. Owned by one AP. A template presets it through its Template Entries: at render time — or, for a template the provider applied, when Brain reads the created Ingresses back — Brain follows the Open Entry — the declared one, else the template's Sealos App CR url — through the Ingress rule whose host and longest-prefix path serve it to the Service port behind it, and writes the annotation there unless the template already set it, so the node's Open and the Deployment Task Success Record's Open agree; no match writes nothing (ADR 0081). _Avoid_: primary entry, primary port, primary address, launch link, main domain, Project open link, Template Instance open link. @@ -360,7 +360,7 @@ _Avoid_: guessed URL, inferred socket address, source-specific public access car ### Template Entry -A URL a Sealos Template declares in its header (`spec.entries`) for one of two roles, each a full URL rendered with the template's own `${{ }}` substitution: the Open Entry is what the Open control opens after the deployment, and the Share Entry is what the Deployment Task Success Record's share strip shares. When a template declares no Open Entry, its Sealos App CR url (`spec.data.url`) stands in; with neither, the Default Open Port's automatic rule decides. The Share Entry has one fallback only, the Open URL — never the App CR url, which may embed a secret. An entry is kept only when an Ingress of the same deployment serves its host, and that host is a Deployment Access Endpoint whose probe already gates the record — so a Template Entry is not itself probed and is taken as declared, query string and all: whether the application answers on its path is not routing health, exactly as Public Address Health has it. The Open Entry is the record's first entry, headed by the App Listening Port it reaches when an AP observed that address; the Share Entry is the record's share address and is never listed as an entry. The Open Entry also presets the AP's Default Open Port (ADR 0081). +A URL a Sealos Template declares in its header (`spec.entries`) for one of two roles, each a full URL rendered with the template's own `${{ }}` substitution: the Open Entry is what the Open control opens after the deployment, and the Share Entry is what the Deployment Task Success Record's share strip shares. When a template declares no Open Entry, its Sealos App CR url (`spec.data.url`) stands in; with neither, the Default Open Port's automatic rule decides. The Share Entry has one fallback only, the Open URL — never the App CR url, which may embed a secret. An entry is kept only when an Ingress of the same deployment serves its host, and that host is a Deployment Access Endpoint whose probe already gates the record — so a Template Entry is not itself probed and is taken as declared, query string and all: whether the application answers on its path is not routing health, exactly as AP Public Access Health has it. The Open Entry is the record's first entry, headed by the App Listening Port it reaches when an AP observed that address; the Share Entry is the record's share address and is never listed as an entry. Only an HTTP(S) Share Entry is shared: a socket address (`wss://`) cannot be opened from a link or a QR code, so it yields to the Open URL as if no Share Entry were declared. The Open Entry also presets the AP's Default Open Port (ADR 0081). _Avoid_: primary entry, launch link, main domain, share project, share deployment, App CR link (as the product term), entry point (for the field). diff --git a/apps/ui/src/features/deploy/deployment-task-success-section.tsx b/apps/ui/src/features/deploy/deployment-task-success-section.tsx index 791442007..62d0be462 100644 --- a/apps/ui/src/features/deploy/deployment-task-success-section.tsx +++ b/apps/ui/src/features/deploy/deployment-task-success-section.tsx @@ -352,7 +352,7 @@ export const DeploymentTaskSuccessSection = memo( )} - {primaryEntry == null || shareUrl == null ? null : ( + {shareUrl == null ? null : ( ; + args?: Record; instanceName: string; templateName: string; } { @@ -2788,7 +2790,6 @@ function templateProviderEntryContext(artifact: DeploymentArtifact): { }; case "template-instance": return { - args: {}, instanceName: artifact.instanceName, templateName: artifact.templateName, }; diff --git a/apps/ui/src/features/deploy/task/template-provider-entries.test.ts b/apps/ui/src/features/deploy/task/template-provider-entries.test.ts index af19bb4b2..d9694811a 100644 --- a/apps/ui/src/features/deploy/task/template-provider-entries.test.ts +++ b/apps/ui/src/features/deploy/task/template-provider-entries.test.ts @@ -167,7 +167,7 @@ const RESOURCES = [ { name: "eaglercraft-admin", resourceType: "ingress", uid: "4" }, ]; -function readBack(args: Record) { +function readBack(args: Record | undefined) { return templateProviderTemplateEntries({ args, instanceName: "eaglercraft", @@ -255,6 +255,19 @@ describe("templateProviderTemplateEntries", () => { expect(entries).toEqual({ open: `https://${HOST}/admin` }); }); + it("drops an input-bound entry instead of rendering it from the default when no args are in hand", async () => { + installCluster(clusterObjects()); + + const entries = await readBack(undefined); + + // Open substitutes no input and renders; Share names `inputs.server_name` + // and is dropped rather than snapshotted with "Lobby". + expect(entries).toEqual({ open: `https://${HOST}/admin` }); + expect(patches.map((patch) => patch.body)).toEqual([ + { metadata: { annotations: { "brain.io/default-open-port": "5201" } } }, + ]); + }); + it("degrades to no entries when the provider source cannot be read", async () => { providerSource = () => Promise.reject(new Error("provider down")); installCluster(clusterObjects()); diff --git a/apps/ui/src/features/deploy/task/template-provider-entries.ts b/apps/ui/src/features/deploy/task/template-provider-entries.ts index 449eb55fa..99636c588 100644 --- a/apps/ui/src/features/deploy/task/template-provider-entries.ts +++ b/apps/ui/src/features/deploy/task/template-provider-entries.ts @@ -5,6 +5,7 @@ import { fetcher } from "@workspace/api/fetch"; import { ApiUrl } from "@workspace/api/utils"; import { resolveTemplateEntryUrls, + type TemplateDeclaredEntries, type TemplateEntryUrls, templateAppUrlFromDocs, templateDeclaredEntries, @@ -148,8 +149,42 @@ function providerInputDefaults(source: unknown): Record { * Entries are advisory: any failure here degrades to no declared entry and * the automatic rule, never to a failed deployment. */ +/** Whether a declared entry substitutes a user input (`${{ inputs.* }}`). */ +function entrySubstitutesInputs(value: string): boolean { + return TEMPLATE_INPUT_EXPRESSION.test(value); +} + +const TEMPLATE_INPUT_EXPRESSION = /\$\{\{[^}]*\binputs\./; + +/** + * The declared entries this run can render truthfully: with no args in hand + * (an already-created instance), an entry that substitutes an input is + * dropped rather than rendered from the template's default, which the user + * may have overridden at create time. + */ +function renderableDeclaredEntries( + declared: TemplateDeclaredEntries, + args: Record | undefined +): TemplateDeclaredEntries { + if (args !== undefined) { + return declared; + } + return { + ...(declared.open === undefined || entrySubstitutesInputs(declared.open) + ? {} + : { open: declared.open }), + ...(declared.share === undefined || entrySubstitutesInputs(declared.share) + ? {} + : { share: declared.share }), + }; +} + export async function templateProviderTemplateEntries(input: { - args: Record; + /** + * This run's create-time args, memory only. Absent for an instance created + * before this run; input-bound entries are then dropped, not defaulted. + */ + args?: Record; instanceName: string; kubeconfig: string; namespace: string; @@ -184,7 +219,10 @@ async function resolveProviderEntries( encodedKubeconfig: input.kubeconfig, templateName: input.templateName, }); - const declared = templateDeclaredEntries(source.templateYaml); + const declared = renderableDeclaredEntries( + templateDeclaredEntries(source.templateYaml), + input.args + ); const appNames = new Set([ ...resourceNames(input.resources, APP_RESOURCE_TYPES), input.instanceName, @@ -214,7 +252,10 @@ async function resolveProviderEntries( ...templateInstanceDefaults(instance), app_name: input.instanceName, }, - inputs: { ...providerInputDefaults(source.source), ...input.args }, + inputs: { + ...providerInputDefaults(source.source), + ...(input.args ?? {}), + }, namespace: input.namespace, routingDomain: input.routingDomain, }); diff --git a/apps/ui/src/features/deploy/task/timeline.test.ts b/apps/ui/src/features/deploy/task/timeline.test.ts index c5c6a242e..2ddd5ed27 100644 --- a/apps/ui/src/features/deploy/task/timeline.test.ts +++ b/apps/ui/src/features/deploy/task/timeline.test.ts @@ -1307,6 +1307,21 @@ test("a template's Open Entry leads the record and its Share Entry is the share assert.equal(success?.shareUrl, EAGLER_SHARE); // Declared entries are not probes; they never touch the verification count. assert.deepEqual(success?.verification, { passed: 4, total: 4 }); + + // The card's verified label stands even when the Open Entry carries none. + const unlabeled = deploymentTaskSuccessFromTimeline( + eaglercraftTimeline({ adminObserved: true }), + { + primaryEntryUrl: `https://${EAGLER_HOST}/`, + productName: "EaglerCraft Server", + templateEntries: { open: EAGLER_OPEN }, + } + ); + assert.deepEqual(unlabeled?.entries?.[0], { + label: "admin · 5201", + protocol: "https", + url: EAGLER_OPEN, + }); }); test("an Open Entry no card lists is added as declared, headed by the port it reaches", () => { diff --git a/apps/ui/src/features/deploy/task/timeline.ts b/apps/ui/src/features/deploy/task/timeline.ts index 53cc6c9a5..588d4e519 100644 --- a/apps/ui/src/features/deploy/task/timeline.ts +++ b/apps/ui/src/features/deploy/task/timeline.ts @@ -1095,8 +1095,13 @@ export function deploymentTaskSuccessFromTimeline( input.templateEntries?.open, input.templateOpenEntryLabel ); + // A URL an Ingress card already lists keeps the card's entry — its label + // was verified; the Open Entry is added only when no card names it. const declaredEntries = - openEntry == null ? endpointEntries : [openEntry, ...endpointEntries]; + openEntry == null || + endpointEntries.some((entry) => entry.url === openEntry.url) + ? endpointEntries + : [openEntry, ...endpointEntries]; const uniqueEntries = prioritizeSuccessEntries( declaredEntries.filter( (entry, index) => diff --git a/apps/ui/src/features/deploy/template-entries.test.ts b/apps/ui/src/features/deploy/template-entries.test.ts index 4a2acba48..ef5bab19e 100644 --- a/apps/ui/src/features/deploy/template-entries.test.ts +++ b/apps/ui/src/features/deploy/template-entries.test.ts @@ -240,6 +240,69 @@ test("templateEntryOpenPort finds nothing without a host, path, or Service match ); }); +test("templateEntryOpenPort never falls through to a shorter rule when the longest cannot be resolved", () => { + const consoleRule = { + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { name: "console" }, + spec: { + rules: [ + { + host: HOST, + http: { + paths: [ + { + backend: { service: { name: "console", port: { number: 80 } } }, + path: "/admin", + pathType: "Prefix", + }, + ], + }, + }, + ], + }, + }; + // `/admin` → console (Service not among the documents); `/` → eaglercraft. + // The root rule serves a different backend, so nothing is named. + assert.equal( + templateEntryOpenPort({ + docs: [ + service(), + consoleRule, + ingress("eaglercraft", [{ path: "/", port: 5200 }]), + ], + openUrl: OPEN, + }), + undefined + ); + // The longest rule names a port its Service does not have: same answer. + assert.equal( + templateEntryOpenPort({ + docs: [ + service(), + ingress("eaglercraft", [ + { path: "/", port: 5200 }, + { path: "/admin", port: 9999 }, + ]), + ], + openUrl: OPEN, + }), + undefined + ); + // Equal-length rules still try each other: the second `/admin` resolves. + assert.deepEqual( + templateEntryOpenPort({ + docs: [ + service(), + consoleRule, + ingress("eaglercraft", [{ path: "/admin", port: 5201 }]), + ], + openUrl: OPEN, + }), + { port: 5201, serviceName: "eaglercraft" } + ); +}); + test("stampTemplateEntryOpenPort writes the annotation unless the template preset one", () => { const fresh = service(); assert.equal( diff --git a/apps/ui/src/features/deploy/template-entries.ts b/apps/ui/src/features/deploy/template-entries.ts index b87aa3044..d9f90fe80 100644 --- a/apps/ui/src/features/deploy/template-entries.ts +++ b/apps/ui/src/features/deploy/template-entries.ts @@ -271,8 +271,9 @@ function servicePortNumber( * The Service port the Open URL enters through: the URL host matched to an * Ingress rule host, the rule path that is the longest prefix of the URL * path, and that path's backend Service and port (a named port resolved - * through the Service's own `spec.ports`). Nothing when no rule matches or - * the Service is not among the documents. + * through the Service's own `spec.ports`). Nothing when no rule matches, or + * when the longest-prefix rule's Service or port cannot be resolved — a + * shorter rule never stands in for it. */ export function templateEntryOpenPort(input: { docs: readonly unknown[]; @@ -292,7 +293,14 @@ export function templateEntryOpenPort(input: { .filter(isIngressDoc) .flatMap((doc) => ingressBackendsForUrl(doc, url)) .sort((a, b) => b.pathLength - a.pathLength); + // Only the longest-prefix rule may name the port. A shorter rule serves a + // different backend; annotating it would make the AP's Open and the + // record's Open disagree — the very thing the annotation exists to prevent. + const longest = matches[0]?.pathLength; for (const match of matches) { + if (match.pathLength !== longest) { + break; + } const service = records.find( (doc) => isServiceDoc(doc) && docName(doc) === match.serviceName ); diff --git a/docs/adr/0081-declare-template-entries-for-open-and-share.md b/docs/adr/0081-declare-template-entries-for-open-and-share.md index 4d91a3c28..59ff40224 100644 --- a/docs/adr/0081-declare-template-entries-for-open-and-share.md +++ b/docs/adr/0081-declare-template-entries-for-open-and-share.md @@ -58,8 +58,8 @@ host (the boundary below), and every such host is a required Deployment Access Endpoint whose probe already gates the Success Record — so the entry's host is verified before the record exists. What a probe of the full URL would add is whether the application answers on that path or query string -at that moment, and that is not routing health: CONTEXT.md's Public Address -Health already says a workload 404 or 500 does not make an address +at that moment, and that is not routing health: CONTEXT.md's AP Public +Access Health already says a workload 404 or 500 does not make an address unhealthy. So the record takes the entries as declared, and a template's own mistake in a path is the template author's to see and fix, not a reason to fall back silently. The Open Entry is the record's first entry; when an @@ -67,12 +67,16 @@ Ingress card already lists the same URL the card's entry stands (the record de-duplicates by full URL), else the entry is added as declared, headed by the App Listening Port it reaches when an AP of the task observed that address and by nothing otherwise. The Share Entry is not listed as an entry -at all: it is the address the share strip shares, and only that. +at all: it is the address the share strip shares, and only that. Only an +HTTP(S) Share Entry is shared: a `ws://` or `wss://` address cannot be opened +from a link or a QR code, so such a Share Entry yields to the Open URL exactly +as if none were declared (the record's `shareUrl` is never a socket address). **Consistency with the AP.** When the Open URL — declared or from the App CR — can be matched to one App Listening Port of one of the deployment's APs, Brain writes `brain.io/default-open-port: ""` onto that AP's Service -at render time, unless the template already set it. The match: the URL host +at render time — at read-back time for a template the provider applied — +unless the template already set it. The match: the URL host equals an Ingress rule host in the rendered documents; the rule path that is the longest prefix of the URL path is followed to its backend Service and port (a named port resolved through the Service's own `spec.ports`). No @@ -117,7 +121,7 @@ shares its primary HTTP(S) entry, as it always did. failed probe would silently swap the declared Open for the automatic rule while the Service annotation still named the declared port, and the user would see two different Opens with no explanation. Either way the probe - verifies application response on a path, which the Public Address Health + verifies application response on a path, which the AP Public Access Health definition already excludes from health; the host is verified by the Ingress endpoint's own required probe. - **List the Share Entry as a record entry** — rejected: the record's entries From 0a7aa335051442c0c9f74eadf43dcef7e79846fe Mon Sep 17 00:00:00 2001 From: aimeritething Date: Wed, 9 Sep 2026 11:08:47 +0800 Subject: [PATCH 5/5] fix(deploy): address the second PR #346 review of Template Entries - A fragment disqualifies Share only: a desktop-launcher App CR URL (`#token=`) presets Open as declared, and the share strip skips a fragment-bearing Open for the next verified HTTP(S) entry - A failed Default Open Port PATCH warns and keeps the resolved entries - `templateDeclaredEntries` reads the inline YAML string as well as the parsed Template object (header splitter moved to template-inline-yaml.ts) - The Open Entry de-duplicates with an Ingress card by canonical URL - The provider read-back fetches the Services the Ingress backends name, not only those the resource summary lists, and warns on non-404 reads - An entry is headed only by the port its exact address reaches - `getTemplateSource` takes the readiness deadline signal Co-Authored-By: Claude Fable 5.1 --- CONTEXT.md | 4 +- .../deploy/task/ap-network-view.test.ts | 15 ++- .../features/deploy/task/ap-network-view.ts | 25 ++-- apps/ui/src/features/deploy/task/artifacts.ts | 2 +- .../task/template-provider-entries.test.ts | 120 +++++++++++++++--- .../deploy/task/template-provider-entries.ts | 58 +++++++-- .../src/features/deploy/task/timeline.test.ts | 120 ++++++++++++++++++ apps/ui/src/features/deploy/task/timeline.ts | 71 +++++++++-- .../features/deploy/template-entries.test.ts | 98 ++++++++++++-- .../src/features/deploy/template-entries.ts | 94 ++++++++++++-- .../features/deploy/template-inline-yaml.ts | 32 +++++ .../features/deploy/template-provider-core.ts | 3 + .../src/features/deploy/template-renderer.ts | 27 +--- ...are-template-entries-for-open-and-share.md | 15 ++- 14 files changed, 567 insertions(+), 117 deletions(-) create mode 100644 apps/ui/src/features/deploy/template-inline-yaml.ts diff --git a/CONTEXT.md b/CONTEXT.md index a18bcc0e3..4274dd952 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -360,7 +360,7 @@ _Avoid_: guessed URL, inferred socket address, source-specific public access car ### Template Entry -A URL a Sealos Template declares in its header (`spec.entries`) for one of two roles, each a full URL rendered with the template's own `${{ }}` substitution: the Open Entry is what the Open control opens after the deployment, and the Share Entry is what the Deployment Task Success Record's share strip shares. When a template declares no Open Entry, its Sealos App CR url (`spec.data.url`) stands in; with neither, the Default Open Port's automatic rule decides. The Share Entry has one fallback only, the Open URL — never the App CR url, which may embed a secret. An entry is kept only when an Ingress of the same deployment serves its host, and that host is a Deployment Access Endpoint whose probe already gates the record — so a Template Entry is not itself probed and is taken as declared, query string and all: whether the application answers on its path is not routing health, exactly as AP Public Access Health has it. The Open Entry is the record's first entry, headed by the App Listening Port it reaches when an AP observed that address; the Share Entry is the record's share address and is never listed as an entry. Only an HTTP(S) Share Entry is shared: a socket address (`wss://`) cannot be opened from a link or a QR code, so it yields to the Open URL as if no Share Entry were declared. The Open Entry also presets the AP's Default Open Port (ADR 0081). +A URL a Sealos Template declares in its header (`spec.entries`) for one of two roles, each a full URL rendered with the template's own `${{ }}` substitution: the Open Entry is what the Open control opens after the deployment, and the Share Entry is what the Deployment Task Success Record's share strip shares. When a template declares no Open Entry, its Sealos App CR url (`spec.data.url`) stands in; with neither, the Default Open Port's automatic rule decides. The Share Entry has one fallback only, the Open URL when it carries no fragment — never the App CR url, which may embed a secret (`#token=…`), and never a fragment-bearing Open, which the record opens but the strip skips for its next verified HTTP(S) entry. An entry is kept only when an Ingress of the same deployment serves its host, and that host is a Deployment Access Endpoint whose probe already gates the record — so a Template Entry is not itself probed and is taken as declared, query string and all: whether the application answers on its path is not routing health, exactly as AP Public Access Health has it. The Open Entry is the record's first entry, headed by the App Listening Port it reaches when an AP observed that address; the Share Entry is the record's share address and is never listed as an entry. Only an HTTP(S) Share Entry is shared: a socket address (`wss://`) cannot be opened from a link or a QR code, so it yields to the Open URL as if no Share Entry were declared. The Open Entry also presets the AP's Default Open Port (ADR 0081). _Avoid_: primary entry, launch link, main domain, share project, share deployment, App CR link (as the product term), entry point (for the field). @@ -386,7 +386,7 @@ A Deployment Task Timeline section for one Deployment Result Resource, presentin ### Deployment Task Success Record -The conclusion a Deployment Task Timeline appends once Deployment Result Readiness is reached and every required access endpoint has passed its protocol probe. It carries only facts the deployment declared — product name, verified HTTP or WebSocket entries, the template's Open Entry on a host those entries verified, first-use steps — so the Timeline never presents an address or instruction the runner cannot evidence. Each entry is headed the way a Public Address is shown everywhere else: the Port Display Name form of the App Listening Port it reaches (`game · 5200`, or `5200` alone for an unnamed port), as it stood at verification time; an entry no App Listening Port can be found for keeps the name its source declared. Two entries reaching the same port carry the same heading; the record stays a flat list. A record with a single entry heads it with nothing, whatever heading its source would have given it, as the node draws a lone Public Address. HTTP(S) entries can be opened and copied; WS(S) entries are copied. Its Open control opens the template's Open Entry when there is one, else the Default Open Port through its best Public Address, as decided when the record was written — the record is a snapshot, so a later rename or Default Open Port change does not rewrite it. A verified deployment with no endpoint uses the neutral `Deployment completed` headline, while `You can start using it` is reserved for a verified actionable entry. A task with no required Deployment Result Resource publishes no record and keeps reporting progress. It is part of the task-owned timeline snapshot, not a Chat message or a toast, and its Timeline revision doubles as its identity. Its share address — the template's Share Entry, else its Open URL, snapshotted as `shareUrl` (a record written before the field shares its primary HTTP(S) entry) — may be shared: copied, shown as a QR code, or posted to a social network as the product's own public address; this shares nothing of Brain and is not Public Project Preview Sharing, which no longer exists. Its first-use steps appear under the user-facing heading `Next steps`, and only when the deployment declared them; a record without declared steps shows no heading. A record written for a template deployment also snapshots the template's catalog name as its product id and the template's declared categories (`game`, `ai`, …) as they stood when the task was created, so the share copy can speak to a game or an AI app without asking the catalog again; other sources declare neither. +The conclusion a Deployment Task Timeline appends once Deployment Result Readiness is reached and every required access endpoint has passed its protocol probe. It carries only facts the deployment declared — product name, verified HTTP or WebSocket entries, the template's Open Entry on a host those entries verified, first-use steps — so the Timeline never presents an address or instruction the runner cannot evidence. Each entry is headed the way a Public Address is shown everywhere else: the Port Display Name form of the App Listening Port it reaches (`game · 5200`, or `5200` alone for an unnamed port), as it stood at verification time; an entry no App Listening Port can be found for keeps the name its source declared. Two entries reaching the same port carry the same heading; the record stays a flat list. A record with a single entry heads it with nothing, whatever heading its source would have given it, as the node draws a lone Public Address. HTTP(S) entries can be opened and copied; WS(S) entries are copied. Its Open control opens the template's Open Entry when there is one, else the Default Open Port through its best Public Address, as decided when the record was written — the record is a snapshot, so a later rename or Default Open Port change does not rewrite it. A verified deployment with no endpoint uses the neutral `Deployment completed` headline, while `You can start using it` is reserved for a verified actionable entry. A task with no required Deployment Result Resource publishes no record and keeps reporting progress. It is part of the task-owned timeline snapshot, not a Chat message or a toast, and its Timeline revision doubles as its identity. Its share address — the template's Share Entry, else its Open URL when fragment-free, else its next verified HTTP(S) entry, snapshotted as `shareUrl` (a record written before the field shares its primary fragment-free HTTP(S) entry) — may be shared: copied, shown as a QR code, or posted to a social network as the product's own public address; this shares nothing of Brain and is not Public Project Preview Sharing, which no longer exists. Its first-use steps appear under the user-facing heading `Next steps`, and only when the deployment declared them; a record without declared steps shows no heading. A record written for a template deployment also snapshots the template's catalog name as its product id and the template's declared categories (`game`, `ai`, …) as they stood when the task was created, so the share copy can speak to a game or an AI app without asking the catalog again; other sources declare neither. _Avoid_: success toast, deploy done banner, completion notification, "Public address" as an entry heading, share project, share deployment. diff --git a/apps/ui/src/features/deploy/task/ap-network-view.test.ts b/apps/ui/src/features/deploy/task/ap-network-view.test.ts index c971c5c54..f938484ed 100644 --- a/apps/ui/src/features/deploy/task/ap-network-view.test.ts +++ b/apps/ui/src/features/deploy/task/ap-network-view.test.ts @@ -163,15 +163,18 @@ describe("AP network view", () => { expect( apNetworkViewAddressForUrl(parsed, "wss://shared.example.com/")?.port ).toBe(5200); - // Same scheme, unknown path: the scheme still picks the row. + // Same host, another path or scheme: no row reaches that address, so a + // Success Record entry for it is headed by nothing rather than by the + // wrong port. expect( apNetworkViewAddressForUrl(parsed, "https://shared.example.com/other") - ?.port - ).toBe(8081); - // Unknown scheme falls back to the host alone. + ).toBe(undefined); expect( - apNetworkViewAddressForUrl(parsed, "http://shared.example.com/")?.port - ).toBe(5200); + apNetworkViewAddressForUrl(parsed, "https://shared.example.com/") + ).toBe(undefined); + expect( + apNetworkViewAddressForUrl(parsed, "http://shared.example.com/") + ).toBe(undefined); expect(apNetworkViewAddressForUrl(parsed, "https://nobody.example/")).toBe( undefined ); diff --git a/apps/ui/src/features/deploy/task/ap-network-view.ts b/apps/ui/src/features/deploy/task/ap-network-view.ts index e3defc683..7eee67603 100644 --- a/apps/ui/src/features/deploy/task/ap-network-view.ts +++ b/apps/ui/src/features/deploy/task/ap-network-view.ts @@ -195,8 +195,10 @@ function parseEndpointUrl(raw: string | undefined): ParsedEndpointUrl | null { * The address row an endpoint URL reaches. One hostname may expose several * Public Addresses that differ by protocol or path and target different * App Listening Ports (ADR 0079: a `wss://` game port beside an `https://` - * admin path), so the URL is matched whole first — scheme, host, and entry - * path — then by scheme and host, and only then by host alone. + * admin path), so the URL is matched whole — scheme, host, and entry path — + * and nothing looser: an entry is headed by the port its address reaches, + * else by nothing (CONTEXT.md: Deployment Task Success Record), so a + * same-host row for another port must never stand in. */ export function apNetworkViewAddressForUrl( view: ApNetworkView, @@ -206,17 +208,14 @@ export function apNetworkViewAddressForUrl( if (wanted === null) { return undefined; } - const candidates = view.addresses - .filter((address) => address.host === wanted.host) - .map((address) => ({ address, parsed: parseEndpointUrl(address.url) })); - const exact = candidates.find( - ({ parsed }) => - parsed?.scheme === wanted.scheme && parsed.path === wanted.path - ); - const sameScheme = candidates.find( - ({ parsed }) => parsed?.scheme === wanted.scheme - ); - return (exact ?? sameScheme ?? candidates[0])?.address; + return view.addresses.find((address) => { + const parsed = parseEndpointUrl(address.url); + return ( + address.host === wanted.host && + parsed?.scheme === wanted.scheme && + parsed.path === wanted.path + ); + }); } /** diff --git a/apps/ui/src/features/deploy/task/artifacts.ts b/apps/ui/src/features/deploy/task/artifacts.ts index fe4b6da07..913bafa1e 100644 --- a/apps/ui/src/features/deploy/task/artifacts.ts +++ b/apps/ui/src/features/deploy/task/artifacts.ts @@ -2,13 +2,13 @@ import YAML from "yaml"; import { childResourceName } from "@/features/deploy/project-child-resource-name"; import { joinKubeYamlDocuments } from "@/features/deploy/render-yaml-template"; +import { templateHeaderFromInlineYaml } from "@/features/deploy/template-inline-yaml"; import { evaluateTemplateCondition, type RenderedTemplateDeployment, renderTemplateDeploymentFromYaml, resolveTemplateDeclarationState, type TemplateEvaluationContext, - templateHeaderFromInlineYaml, templateSourceFromInlineYaml, } from "@/features/deploy/template-renderer"; diff --git a/apps/ui/src/features/deploy/task/template-provider-entries.test.ts b/apps/ui/src/features/deploy/task/template-provider-entries.test.ts index d9694811a..b4ce4eea7 100644 --- a/apps/ui/src/features/deploy/task/template-provider-entries.test.ts +++ b/apps/ui/src/features/deploy/task/template-provider-entries.test.ts @@ -42,7 +42,10 @@ const patches: RecordedPatch[] = []; let providerSource: () => Promise = () => Promise.resolve(new Response(null, { status: 500 })); -function installCluster(objects: Record>) { +function installCluster( + objects: Record>, + options: { patchStatus?: number } = {} +) { globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(String(input)); if (url.pathname.endsWith("/api/getTemplateSource")) { @@ -54,7 +57,11 @@ function installCluster(objects: Record>) { body: JSON.parse(String(init?.body ?? "null")), query, }); - return Promise.resolve(Response.json({})); + return Promise.resolve( + options.patchStatus === undefined + ? Response.json({}) + : new Response("forbidden", { status: options.patchStatus }) + ); } if (url.pathname.endsWith("/api/k8s/v1alpha1/get")) { const object = objects[query.kind ?? ""]?.[query.name ?? ""]; @@ -160,13 +167,28 @@ function clusterObjects(input: { preset?: string } = {}) { } as Record>; } +// As the provider summarizes a catalog deployment: the workload and its +// Ingresses, no Service. The Service is reached through the Ingress backends. const RESOURCES = [ - { name: "eaglercraft", resourceType: "deployment", uid: "1" }, - { name: "eaglercraft", resourceType: "service", uid: "2" }, + { name: "eaglercraft", resourceType: "StatefulSet", uid: "1" }, { name: "eaglercraft", resourceType: "ingress", uid: "3" }, - { name: "eaglercraft-admin", resourceType: "ingress", uid: "4" }, + { name: "eaglercraft-admin", resourceType: "Ingress", uid: "4" }, ]; +function providerSourceResponse(templateYaml: unknown) { + return Response.json({ + code: 200, + data: { + appYaml: "kind: Deployment", + source: { + defaults: {}, + inputs: [{ default: "Lobby", key: "server_name" }], + }, + templateYaml, + }, + }); +} + function readBack(args: Record | undefined) { return templateProviderTemplateEntries({ args, @@ -184,19 +206,7 @@ describe("templateProviderTemplateEntries", () => { process.env.TEMPLATE_PROVIDER_URL = "https://provider.test"; patches.length = 0; providerSource = () => - Promise.resolve( - Response.json({ - code: 200, - data: { - appYaml: "kind: Deployment", - source: { - defaults: {}, - inputs: [{ default: "Lobby", key: "server_name" }], - }, - templateYaml: TEMPLATE_YAML, - }, - }) - ); + Promise.resolve(providerSourceResponse(TEMPLATE_YAML)); }); afterEach(() => { @@ -275,4 +285,78 @@ describe("templateProviderTemplateEntries", () => { await expect(readBack({})).resolves.toBeUndefined(); expect(patches).toEqual([]); }); + + it("reads the entries off an inline YAML template source as well as a parsed one", async () => { + providerSource = () => + Promise.resolve( + providerSourceResponse( + [ + "apiVersion: app.sealos.io/v1", + "kind: Template", + "metadata:", + " name: eaglercraft-server", + "spec:", + " entries:", + ` open: https://${APP_HOST_EXPRESSION}/admin`, + "---", + "apiVersion: v1", + "kind: Service", + ].join("\n") + ) + ); + installCluster(clusterObjects()); + + const entries = await readBack({}); + + expect(entries).toEqual({ open: `https://${HOST}/admin` }); + expect(patches.map((patch) => patch.body)).toEqual([ + { metadata: { annotations: { "brain.io/default-open-port": "5201" } } }, + ]); + }); + + it("keeps the resolved entries when the Default Open Port PATCH fails", async () => { + installCluster(clusterObjects(), { patchStatus: 403 }); + const warn = console.warn; + const warnings: unknown[][] = []; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + try { + const entries = await readBack({ server_name: "My Server" }); + + expect(entries).toEqual({ + open: `https://${HOST}/admin`, + share: `https://${HOST}/?server=wss://${HOST}/&name=My Server`, + }); + expect(patches).toHaveLength(1); + expect(String(warnings[0]?.[0])).toContain("Default Open Port"); + } finally { + console.warn = warn; + } + }); + + it("presets the Open Entry's App CR launcher URL and leaves Share empty", async () => { + providerSource = () => + Promise.resolve( + providerSourceResponse({ ...TEMPLATE_YAML, spec: { title: "x" } }) + ); + const objects = clusterObjects(); + objects.apps = { + eaglercraft: { + apiVersion: "app.sealos.io/v1", + kind: "App", + metadata: { name: "eaglercraft" }, + spec: { data: { url: `https://${HOST}/#token=abc` } }, + }, + }; + installCluster(objects); + + const entries = await readBack({}); + + expect(entries).toEqual({ open: `https://${HOST}/#token=abc` }); + // The fragment is client-side: the root rule's port is preset. + expect(patches.map((patch) => patch.body)).toEqual([ + { metadata: { annotations: { "brain.io/default-open-port": "5200" } } }, + ]); + }); }); diff --git a/apps/ui/src/features/deploy/task/template-provider-entries.ts b/apps/ui/src/features/deploy/task/template-provider-entries.ts index 99636c588..66378734a 100644 --- a/apps/ui/src/features/deploy/task/template-provider-entries.ts +++ b/apps/ui/src/features/deploy/task/template-provider-entries.ts @@ -11,6 +11,7 @@ import { templateDeclaredEntries, templateEntryOpenPort, templateIngressHostsFromDocs, + templateIngressServiceNamesFromDocs, templateInstanceDefaults, } from "@/features/deploy/template-entries"; import { @@ -71,10 +72,23 @@ async function getObject(input: { if (input.signal?.aborted) { throw error; } + // An absent object is an answer (the App CR is looked up by a guessed + // name); any other failure is a degraded read worth a trace. + if (!isNotFoundError(error)) { + console.warn( + `[deploy-task] Could not read ${input.kind}/${input.name} in ${input.namespace} for template entries.`, + error + ); + } return null; } } +/** `fetcher` throws `API : …` for a non-OK response. */ +function isNotFoundError(error: unknown): boolean { + return error instanceof Error && error.message.startsWith("API 404"); +} + async function getObjects(input: { kind: string; kubeconfig: string; @@ -217,6 +231,7 @@ async function resolveProviderEntries( }; const source = await getTemplateSource({ encodedKubeconfig: input.kubeconfig, + signal: input.signal, templateName: input.templateName, }); const declared = renderableDeclaredEntries( @@ -227,22 +242,29 @@ async function resolveProviderEntries( ...resourceNames(input.resources, APP_RESOURCE_TYPES), input.instanceName, ]); - const [ingresses, services, apps, instance] = await Promise.all([ + const [ingresses, apps, instance] = await Promise.all([ getObjects({ ...read, kind: "ingresses", names: resourceNames(input.resources, INGRESS_RESOURCE_TYPES), }), - getObjects({ - ...read, - kind: "services", - names: resourceNames(input.resources, SERVICE_RESOURCE_TYPES), - }), getObjects({ ...read, kind: "apps", names: [...appNames] }), declared.open === undefined && declared.share === undefined ? Promise.resolve(null) : getObject({ ...read, kind: "instances", name: input.instanceName }), ]); + // The provider's summary often lists Ingresses and no Service, so the + // Services are the ones the Ingress backends name, plus any it did list. + const services = await getObjects({ + ...read, + kind: "services", + names: [ + ...new Set([ + ...templateIngressServiceNamesFromDocs(ingresses), + ...resourceNames(input.resources, SERVICE_RESOURCE_TYPES), + ]), + ], + }); const rendered = declared.open === undefined && declared.share === undefined ? {} @@ -272,12 +294,24 @@ async function resolveProviderEntries( objectValue(candidate.metadata)?.name === target?.serviceName ); if (target !== undefined && service !== undefined) { - await presetDefaultOpenPort({ - ...read, - port: target.port, - service, - serviceName: target.serviceName, - }); + // The annotation is bookkeeping for the AP's Open control; a failed + // PATCH must not erase the entries the reads already resolved. + try { + await presetDefaultOpenPort({ + ...read, + port: target.port, + service, + serviceName: target.serviceName, + }); + } catch (error) { + if (input.signal?.aborted) { + throw error; + } + console.warn( + `[deploy-task] Could not preset the Default Open Port on Service ${target.serviceName} for instance ${input.instanceName}.`, + error + ); + } } } return entries.open === undefined && entries.share === undefined diff --git a/apps/ui/src/features/deploy/task/timeline.test.ts b/apps/ui/src/features/deploy/task/timeline.test.ts index 2ddd5ed27..a68ffeed1 100644 --- a/apps/ui/src/features/deploy/task/timeline.test.ts +++ b/apps/ui/src/features/deploy/task/timeline.test.ts @@ -1409,3 +1409,123 @@ test("the share address is a snapshot the sanitizer keeps for HTTP(S) only, and }) ); }); + +function httpsRootTimeline(rootUrl: string) { + return upsertResultResourceCard( + timelineFrame({ + "AP:default:eaglercraft": "running", + "PublicAccess:default:eaglercraft:lobby": "running", + }), + { + card: endpointCard({ + id: "ingress:eaglercraft:https:root", + label: "web · 8080", + observer: { kind: "ingress", name: "eaglercraft" }, + protocol: "https", + required: true, + status: "running", + url: rootUrl, + }), + stepId: "create-resources", + updatedAt: NOW, + } + ); +} + +test("an Open Entry is de-duplicated with an Ingress card by canonical URL, not spelling", () => { + // The App CR keeps `https://host`; the card writes `https://host/`. + for (const spelling of [ + `https://${EAGLER_HOST}`, + `https://${EAGLER_HOST.toUpperCase()}:443/`, + ]) { + const success = deploymentTaskSuccessFromTimeline( + httpsRootTimeline(`https://${EAGLER_HOST}/`), + { + primaryEntryUrl: null, + productName: null, + templateEntries: { open: spelling }, + } + ); + assert.deepEqual(success?.entries, [ + { + label: "web · 8080", + protocol: "https", + url: `https://${EAGLER_HOST}/`, + }, + ]); + assert.equal(success?.shareUrl, `https://${EAGLER_HOST}/`); + } + // A different path, query, or fragment is another address. + for (const other of [ + `https://${EAGLER_HOST}/admin`, + `https://${EAGLER_HOST}/?server=x`, + `https://${EAGLER_HOST}/#token=abc`, + ]) { + const success = deploymentTaskSuccessFromTimeline( + httpsRootTimeline(`https://${EAGLER_HOST}/`), + { + primaryEntryUrl: null, + productName: null, + templateEntries: { open: other }, + } + ); + assert.equal(success?.entries?.length, 2); + assert.equal(success?.entries?.[0]?.url, other); + } +}); + +test("a fragment-bearing Open Entry is opened as declared and never shared", () => { + const launcher = `https://${EAGLER_HOST}/#token=abc`; + // With a verified HTTP(S) root beside it, the strip shares the root. + const withRoot = deploymentTaskSuccessFromTimeline( + httpsRootTimeline(`https://${EAGLER_HOST}/`), + { + primaryEntryUrl: `https://${EAGLER_HOST}/`, + productName: null, + templateEntries: { open: launcher }, + } + ); + assert.equal(withRoot?.entries?.[0]?.url, launcher); + assert.equal(withRoot?.shareUrl, `https://${EAGLER_HOST}/`); + // With only a socket beside it, nothing is shared. + const socketOnly = deploymentTaskSuccessFromTimeline( + eaglercraftTimeline({ adminObserved: false }), + { + primaryEntryUrl: null, + productName: null, + templateEntries: { open: launcher }, + } + ); + assert.deepEqual(socketOnly?.entries?.[0], { + protocol: "https", + url: launcher, + }); + assert.equal(socketOnly?.shareUrl, undefined); + // A declared Share with a fragment is not a share address either. + const declared = deploymentTaskSuccessFromTimeline( + eaglercraftTimeline({ adminObserved: true }), + { + primaryEntryUrl: null, + productName: null, + templateEntries: { open: EAGLER_OPEN, share: launcher }, + } + ); + assert.equal(declared?.shareUrl, EAGLER_OPEN); + + // The sanitizer keeps such an entry on read-back and never shares it. + const fallback = { revision: 2, verifiedAt: NOW }; + const kept = sanitizeDeploymentTaskSuccess( + { + contractVersion: 2, + entries: [{ protocol: "https", url: launcher }], + shareUrl: launcher, + }, + fallback + ); + assert.deepEqual(kept?.entries, [{ protocol: "https", url: launcher }]); + assert.equal(kept?.shareUrl, undefined); + assert.equal( + deploymentTaskSuccessShareUrl(kept ?? { entries: [] }), + undefined + ); +}); diff --git a/apps/ui/src/features/deploy/task/timeline.ts b/apps/ui/src/features/deploy/task/timeline.ts index 588d4e519..1059e8b31 100644 --- a/apps/ui/src/features/deploy/task/timeline.ts +++ b/apps/ui/src/features/deploy/task/timeline.ts @@ -725,12 +725,18 @@ function successUrl(value: unknown): string | undefined { return successText(value, MAX_SUCCESS_URL_LENGTH); } +/** + * The protocol of an entry URL, or nothing for one the record may not carry. + * Credentials are rejected; a fragment is not — a template's Open Entry may + * be a desktop-launcher URL (`#token=…`), which the Open control opens as + * declared. What may be *shared* is decided separately (`successShareUrl`). + */ function accessProtocol( value: string ): DeploymentAccessEndpointProtocol | undefined { try { const url = new URL(value); - if (url.username !== "" || url.password !== "" || url.hash !== "") { + if (url.username !== "" || url.password !== "") { return undefined; } switch (url.protocol) { @@ -750,14 +756,13 @@ function accessProtocol( } } -/** A share address is opened in a browser, so only HTTP(S) qualifies. */ +/** + * A share address is opened in a browser, so only HTTP(S) qualifies — and + * never a URL with a fragment, which may embed a secret (ADR 0081). + */ function successShareUrl(value: unknown): string | undefined { const url = successUrl(value); - if (url == null) { - return undefined; - } - const protocol = accessProtocol(url); - return protocol === "http" || protocol === "https" ? url : undefined; + return url != null && isShareableEntryUrl(url) ? url : undefined; } function successCount(value: unknown, max: number): number | undefined { @@ -987,6 +992,45 @@ function isHttpEntryUrl(url: string): boolean { } } +/** + * Whether an entry may be the share address: HTTP(S) with no fragment. A + * fragment-bearing Open (an App CR `#token=` launcher URL) is opened, never + * shared — Share falls past it to the next verified HTTP(S) entry. + */ +function isShareableEntryUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ( + (parsed.protocol === "http:" || parsed.protocol === "https:") && + parsed.hash === "" + ); + } catch { + return false; + } +} + +/** + * One address, one spelling: an Ingress card writes a root as + * `https://host/` while a declared entry may keep `https://host`. Host is + * case-insensitive and a default port is implicit; path, query, and fragment + * stay distinct — `/admin` and `/`, `/?server=…` and `/`, `/#token=…` and + * `/` are different addresses. + */ +function canonicalEntryUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.hostname.toLowerCase()}${ + parsed.port === "" ? "" : `:${parsed.port}` + }${parsed.pathname === "" ? "/" : parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return url; + } +} + +function sameEntryUrl(left: string, right: string): boolean { + return canonicalEntryUrl(left) === canonicalEntryUrl(right); +} + /** * Puts the entry the Open control should open first — the record's order is * its priority order, so the first openable entry is the primary action. The @@ -1020,7 +1064,8 @@ export function prioritizeSuccessEntries( /** * The template's Open Entry as a record entry: the declared URL, headed by * the App Listening Port it reaches when one is known. A URL an Ingress card - * already lists keeps that card's entry instead (de-duplicated by full URL). + * already lists keeps that card's entry instead (de-duplicated by canonical + * URL, so `https://host` and `https://host/` are one address). */ function templateOpenEntry( url: string | null | undefined, @@ -1042,7 +1087,7 @@ export function deploymentTaskSuccessShareUrl( ): string | undefined { return ( success.shareUrl ?? - success.entries?.find((entry) => isHttpEntryUrl(entry.url))?.url + success.entries?.find((entry) => isShareableEntryUrl(entry.url))?.url ); } @@ -1099,21 +1144,21 @@ export function deploymentTaskSuccessFromTimeline( // was verified; the Open Entry is added only when no card names it. const declaredEntries = openEntry == null || - endpointEntries.some((entry) => entry.url === openEntry.url) + endpointEntries.some((entry) => sameEntryUrl(entry.url, openEntry.url)) ? endpointEntries : [openEntry, ...endpointEntries]; const uniqueEntries = prioritizeSuccessEntries( declaredEntries.filter( (entry, index) => - declaredEntries.findIndex( - (candidate) => candidate.url === entry.url + declaredEntries.findIndex((candidate) => + sameEntryUrl(candidate.url, entry.url) ) === index ), openEntry?.url ?? input.primaryEntryUrl ); const declaredShare = input.templateEntries?.share; const shareUrl = - declaredShare != null && isHttpEntryUrl(declaredShare) + declaredShare != null && isShareableEntryUrl(declaredShare) ? declaredShare : deploymentTaskSuccessShareUrl({ entries: uniqueEntries }); const success = deploymentTaskSuccessFromResultReadiness({ diff --git a/apps/ui/src/features/deploy/template-entries.test.ts b/apps/ui/src/features/deploy/template-entries.test.ts index ef5bab19e..35bec70c6 100644 --- a/apps/ui/src/features/deploy/template-entries.test.ts +++ b/apps/ui/src/features/deploy/template-entries.test.ts @@ -9,6 +9,7 @@ import { templateEntryOpenPort, templateEntryUrl, templateIngressHostsFromDocs, + templateIngressServiceNamesFromDocs, templateInstanceDefaults, } from "./template-entries"; @@ -99,14 +100,53 @@ test("templateDeclaredEntries reads spec.entries strings and ignores the rest", assert.deepEqual(templateDeclaredEntries(null), {}); }); -test("templateEntryUrl keeps a query string verbatim and rejects secrets and fragments", () => { - assert.equal(templateEntryUrl(SHARE), SHARE); - assert.equal(templateEntryUrl(`wss://${HOST}/`), `wss://${HOST}/`); - assert.equal(templateEntryUrl(`https://${HOST}/#token=abc`), undefined); - assert.equal(templateEntryUrl(`https://user:pw@${HOST}/`), undefined); - assert.equal(templateEntryUrl("ftp://example.com/"), undefined); - assert.equal(templateEntryUrl("/admin"), undefined); - assert.equal(templateEntryUrl(""), undefined); +test("templateDeclaredEntries reads the inline YAML string the provider may hand back", () => { + const inline = [ + "apiVersion: app.sealos.io/v1", + "kind: Template", + "metadata:", + " name: eaglercraft-server", + "spec:", + " entries:", + ` open: https://${APP_HOST_EXPRESSION}.example.sealos.run/admin`, + ` share: "https://${APP_HOST_EXPRESSION}.example.sealos.run/?server=wss://x/"`, + "---", + "apiVersion: v1", + "kind: Service", + `${TEMPLATE_EXPRESSION_START} if(inputs.x) }}`, + "---", + ].join("\n"); + assert.deepEqual(templateDeclaredEntries(inline), { + open: `https://${APP_HOST_EXPRESSION}.example.sealos.run/admin`, + share: `https://${APP_HOST_EXPRESSION}.example.sealos.run/?server=wss://x/`, + }); + // A header without entries, and one that is not YAML at all. + assert.deepEqual( + templateDeclaredEntries("apiVersion: app.sealos.io/v1\nkind: Template\n"), + {} + ); + assert.deepEqual(templateDeclaredEntries("spec: [unterminated"), {}); + assert.deepEqual(templateDeclaredEntries(""), {}); +}); + +test("templateEntryUrl keeps a query string verbatim and rejects secrets; a fragment disqualifies Share only", () => { + assert.equal(templateEntryUrl(SHARE, "share"), SHARE); + assert.equal(templateEntryUrl(`wss://${HOST}/`, "open"), `wss://${HOST}/`); + // A desktop-launcher URL opens as declared; it is never shared. + assert.equal( + templateEntryUrl(`https://${HOST}/#token=abc`, "open"), + `https://${HOST}/#token=abc` + ); + assert.equal( + templateEntryUrl(`https://${HOST}/#token=abc`, "share"), + undefined + ); + for (const role of ["open", "share"] as const) { + assert.equal(templateEntryUrl(`https://user:pw@${HOST}/`, role), undefined); + assert.equal(templateEntryUrl("ftp://example.com/", role), undefined); + assert.equal(templateEntryUrl("/admin", role), undefined); + assert.equal(templateEntryUrl("", role), undefined); + } }); test("templateAppUrlFromDocs reads the Sealos App CR url", () => { @@ -125,6 +165,32 @@ test("templateIngressHostsFromDocs lists every Ingress rule host", () => { ); }); +test("templateIngressServiceNamesFromDocs names the Services the Ingress backends route to", () => { + const console = { + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { name: "console" }, + spec: { + rules: [ + { + host: HOST, + http: { + paths: [ + { backend: { service: { name: "console" } }, path: "/console" }, + { backend: { resource: { name: "bucket" } }, path: "/static" }, + ], + }, + }, + ], + }, + }; + assert.deepEqual( + [...templateIngressServiceNamesFromDocs([...eaglercraftDocs(), console])], + ["eaglercraft", "console"] + ); + assert.deepEqual([...templateIngressServiceNamesFromDocs([service()])], []); +}); + test("Open falls back to the App CR url; Share never does", () => { const hosts = new Set([HOST]); assert.deepEqual( @@ -147,14 +213,26 @@ test("Open falls back to the App CR url; Share never does", () => { }), { open: OPEN, share: SHARE } ); - // A fragment-bearing App CR url is not a probeable entry at all. + // A fragment-bearing App CR url is the desktop launcher's: it presets + // Open as declared, and Share — which never takes the App CR — stays out. assert.deepEqual( resolveTemplateEntryUrls({ appUrl: `https://${HOST}/#token=abc`, declared: {}, hosts, }), - {} + { open: `https://${HOST}/#token=abc` } + ); + // A declared Share with a fragment is dropped; a declared Open keeps it. + assert.deepEqual( + resolveTemplateEntryUrls({ + declared: { + open: `https://${HOST}/#token=abc`, + share: `https://${HOST}/#token=abc`, + }, + hosts, + }), + { open: `https://${HOST}/#token=abc` } ); }); diff --git a/apps/ui/src/features/deploy/template-entries.ts b/apps/ui/src/features/deploy/template-entries.ts index d9f90fe80..ecb64ad16 100644 --- a/apps/ui/src/features/deploy/template-entries.ts +++ b/apps/ui/src/features/deploy/template-entries.ts @@ -1,5 +1,7 @@ +import YAML from "yaml"; import { BRAIN_DEFAULT_OPEN_PORT_ANNOTATION } from "@/lib/brain-labels"; import { ingressEntryPath } from "./task/ingress-entry-path"; +import { templateHeaderFromInlineYaml } from "./template-inline-yaml"; /** * Template Entries (ADR 0081): the `spec.entries` a Sealos Template may @@ -15,9 +17,13 @@ import { ingressEntryPath } from "./task/ingress-entry-path"; * automatic Default Open Port rule takes over downstream. * - Share: the declared `entries.share` only. It never falls back to the App * CR URL, which may embed a secret (`#token=`, `/invite/`); the - * record falls back to the Open URL instead. + * record falls back to the Open URL instead, and never to one that + * carries a fragment. * - An entry is kept only when an Ingress of the same deployment serves its * host: Brain never surfaces an address the deployment did not create. + * - Credentials disqualify any entry. A fragment disqualifies Share only: it + * never reaches the server, but a desktop-launcher Open URL (`#token=`) + * legitimately carries one. */ export interface TemplateDeclaredEntries { @@ -64,11 +70,20 @@ function portNumber(value: unknown): number | undefined { : undefined; } -/** The raw `spec.entries` strings of a Template object, before rendering. */ +/** + * The raw `spec.entries` strings of a Template, before rendering. The + * provider hands the template back either as the inline YAML string or as + * the parsed Template object; both shapes are read, so `spec.entries` is + * never dropped by a shape mismatch. + */ export function templateDeclaredEntries( templateYaml: unknown ): TemplateDeclaredEntries { - const spec = objectValue(objectValue(templateYaml)?.spec); + const template = + typeof templateYaml === "string" + ? templateObjectFromInlineYaml(templateYaml) + : objectValue(templateYaml); + const spec = objectValue(template?.spec); const entries = objectValue(spec?.entries); const open = stringValue(entries?.open); const share = stringValue(entries?.share); @@ -78,12 +93,34 @@ export function templateDeclaredEntries( }; } +/** The leading Template document of an inline template, or nothing. */ +function templateObjectFromInlineYaml( + yaml: string +): Record | null { + try { + const document = YAML.parseDocument( + templateHeaderFromInlineYaml(yaml).headerYaml + ); + return document.errors.length > 0 ? null : objectValue(document.toJS()); + } catch { + return null; + } +} + +/** Which Template Entry a URL is read as; the fragment rule differs. */ +export type TemplateEntryRole = "open" | "share"; + /** * A declared entry as a usable absolute URL, or nothing. The query string is - * kept verbatim (a share link may carry one); credentials and fragments are - * rejected because a fragment never reaches the server. + * kept verbatim (a share link may carry one) and credentials are rejected + * for either role. A fragment never reaches the server, so it disqualifies + * a Share URL; an Open URL may carry one — the Sealos desktop launcher + * addresses the App CR presets Open with (`#token=…`) do. */ -export function templateEntryUrl(value: unknown): string | undefined { +export function templateEntryUrl( + value: unknown, + role: TemplateEntryRole +): string | undefined { const raw = stringValue(value); if (raw === undefined || raw.length > MAX_ENTRY_URL_LENGTH) { return undefined; @@ -94,7 +131,7 @@ export function templateEntryUrl(value: unknown): string | undefined { !ENTRY_PROTOCOLS.has(url.protocol) || url.username !== "" || url.password !== "" || - url.hash !== "" || + (role === "share" && url.hash !== "") || url.hostname === "" ) { return undefined; @@ -168,6 +205,37 @@ export function templateIngressHostsFromDocs( return hosts; } +/** + * Every Service an Ingress rule of the deployment routes to, by name. The + * provider's resource summary often lists the Ingresses and no Service, so + * the read-back path fetches the Services the Ingress backends name. + */ +export function templateIngressServiceNamesFromDocs( + docs: readonly unknown[] +): Set { + const names = new Set(); + for (const value of docs) { + const doc = objectValue(value); + if (doc == null || !isIngressDoc(doc)) { + continue; + } + const rules = objectValue(doc.spec)?.rules; + for (const rule of Array.isArray(rules) ? rules : []) { + const paths = objectValue(objectValue(rule)?.http)?.paths; + for (const path of Array.isArray(paths) ? paths : []) { + const service = objectValue( + objectValue(objectValue(path)?.backend)?.service + ); + const name = stringValue(service?.name); + if (name !== undefined) { + names.add(name); + } + } + } + } + return names; +} + /** * The Open and Share URLs a deployment declares. `hosts` — the Ingress hosts * of the same deployment — gates both: an entry no Ingress serves is dropped. @@ -177,16 +245,20 @@ export function resolveTemplateEntryUrls(input: { declared: TemplateDeclaredEntries; hosts: ReadonlySet; }): TemplateEntryUrls { - const served = (candidate: string | undefined): string | undefined => { - const url = templateEntryUrl(candidate); + const served = ( + candidate: string | undefined, + role: TemplateEntryRole + ): string | undefined => { + const url = templateEntryUrl(candidate, role); if (url === undefined) { return undefined; } const host = entryHostname(url); return host !== undefined && input.hosts.has(host) ? url : undefined; }; - const open = served(input.declared.open) ?? served(input.appUrl); - const share = served(input.declared.share); + const open = + served(input.declared.open, "open") ?? served(input.appUrl, "open"); + const share = served(input.declared.share, "share"); return { ...(open === undefined ? {} : { open }), ...(share === undefined ? {} : { share }), diff --git a/apps/ui/src/features/deploy/template-inline-yaml.ts b/apps/ui/src/features/deploy/template-inline-yaml.ts new file mode 100644 index 000000000..3f3693f3d --- /dev/null +++ b/apps/ui/src/features/deploy/template-inline-yaml.ts @@ -0,0 +1,32 @@ +/** + * Splits a Sealos inline template into its leading Template document and + * the resource source that follows. The inline form is a DSL, not standalone + * YAML: resource documents may carry top-level `if`/`endif` directives, so + * only the header is YAML before expression rendering; the rest is kept + * byte-for-byte for the renderer. + */ +export function templateHeaderFromInlineYaml(yaml: string): { + headerYaml: string; + resourceSourceOffset: number; +} { + const marker = /^---[\t ]*(?:#.*)?\r?$/gm; + const firstMarker = marker.exec(yaml); + if (firstMarker == null) { + return { headerYaml: yaml, resourceSourceOffset: yaml.length }; + } + const resourceMarker = + yaml.slice(0, firstMarker.index).trim() === "" + ? marker.exec(yaml) + : firstMarker; + if (resourceMarker == null) { + return { headerYaml: yaml, resourceSourceOffset: yaml.length }; + } + const resourceSourceOffset = + resourceMarker.index + + resourceMarker[0].length + + (yaml[resourceMarker.index + resourceMarker[0].length] === "\n" ? 1 : 0); + return { + headerYaml: yaml.slice(0, resourceMarker.index), + resourceSourceOffset, + }; +} diff --git a/apps/ui/src/features/deploy/template-provider-core.ts b/apps/ui/src/features/deploy/template-provider-core.ts index 359e95a81..98afa6e86 100644 --- a/apps/ui/src/features/deploy/template-provider-core.ts +++ b/apps/ui/src/features/deploy/template-provider-core.ts @@ -333,6 +333,8 @@ export async function listTemplateCatalog(input?: { export async function getTemplateSource(input: { encodedKubeconfig: string; language?: string; + /** Bounds the provider fetch to the caller's budget (a readiness deadline). */ + signal?: AbortSignal; templateName: string; }): Promise { const response = await fetch( @@ -346,6 +348,7 @@ export async function getTemplateSource(input: { Authorization: headerSafeEncodedKubeconfig(input.encodedKubeconfig), }, method: "GET", + ...(input.signal === undefined ? {} : { signal: input.signal }), } ); const body = await readJsonResponse(response); diff --git a/apps/ui/src/features/deploy/template-renderer.ts b/apps/ui/src/features/deploy/template-renderer.ts index 7abfaee9e..4623756d5 100644 --- a/apps/ui/src/features/deploy/template-renderer.ts +++ b/apps/ui/src/features/deploy/template-renderer.ts @@ -20,6 +20,7 @@ import { type TemplateEntryUrls, templateDeclaredEntries, } from "./template-entries"; +import { templateHeaderFromInlineYaml } from "./template-inline-yaml"; import type { TemplateDefaultValue, TemplateSourceInput, @@ -1632,32 +1633,6 @@ function normalizeTemplateInputs(value: unknown): TemplateSourceInput[] { ); } -export function templateHeaderFromInlineYaml(yaml: string): { - headerYaml: string; - resourceSourceOffset: number; -} { - const marker = /^---[\t ]*(?:#.*)?\r?$/gm; - const firstMarker = marker.exec(yaml); - if (firstMarker == null) { - return { headerYaml: yaml, resourceSourceOffset: yaml.length }; - } - const resourceMarker = - yaml.slice(0, firstMarker.index).trim() === "" - ? marker.exec(yaml) - : firstMarker; - if (resourceMarker == null) { - return { headerYaml: yaml, resourceSourceOffset: yaml.length }; - } - const resourceSourceOffset = - resourceMarker.index + - resourceMarker[0].length + - (yaml[resourceMarker.index + resourceMarker[0].length] === "\n" ? 1 : 0); - return { - headerYaml: yaml.slice(0, resourceMarker.index), - resourceSourceOffset, - }; -} - export function templateSourceFromInlineYaml(yaml: string): { source: TemplateSourcePayload; templateName: string; diff --git a/docs/adr/0081-declare-template-entries-for-open-and-share.md b/docs/adr/0081-declare-template-entries-for-open-and-share.md index 59ff40224..a9516aac7 100644 --- a/docs/adr/0081-declare-template-entries-for-open-and-share.md +++ b/docs/adr/0081-declare-template-entries-for-open-and-share.md @@ -47,10 +47,12 @@ chain: the declared `entries.open`; else the template's App CR **Share** is the URL the Success Record's share strip shares. Its fallback: the declared `entries.share`; else the Open URL, which is what the strip -shared before. Share never falls back to the App CR URL on its own: some App -CR URLs embed a secret (`#token=…`, `/invite/`) that must not be posted -to a social network. It only ever equals `entries.share` or the resolved Open -URL. +shared before — unless that Open URL carries a fragment, in which case the +strip falls past it to the record's next verified HTTP(S) entry, or shares +nothing. Share never falls back to the App CR URL on its own: some App CR +URLs embed a secret (`#token=…`, `/invite/`) that must not be posted +to a social network. It only ever equals `entries.share`, a fragment-free +resolved Open URL, or a verified entry. **No probe.** A Template Entry is not a Deployment Access Endpoint and is not probed. It is kept only when an Ingress of the same deployment serves its @@ -88,7 +90,10 @@ Access Node's Open and the record's Open agree. serves its host. Brain never surfaces an address the deployment did not create, and — for a provider-applied template whose defaults Brain re-reads off the Instance CR — an unresolved expression can never leak into a URL. -Credentials and fragments disqualify an entry outright. +Credentials disqualify an entry outright. A fragment disqualifies Share +only: it never reaches the server, so a share link with one is broken, but +the desktop-launcher URLs the App CR presets Open with (`#token=…`) carry +one legitimately and are opened as declared. **Where it runs.** Brain renders some templates itself and defers others to the template provider. In Brain's renderer the rules run over the documents