diff --git a/CONTEXT.md b/CONTEXT.md index 20fb5e22..4274dd95 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 — 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. +_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, 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 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). + ### 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, 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/deployment-task-success-section.tsx b/apps/ui/src/features/deploy/deployment-task-success-section.tsx index 11169926..62d0be46 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 : ( + {shareUrl == null ? null : ( )} 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 c971c5c5..f938484e 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 e3defc68..7eee6760 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 fe4b6da0..913bafa1 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/result-readiness.ts b/apps/ui/src/features/deploy/task/result-readiness.ts index bd8eb63e..be489ccf 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; @@ -491,7 +491,7 @@ async function accessEndpointReadiness( 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 7f1b9aca..76e9c0a1 100644 --- a/apps/ui/src/features/deploy/task/runner.ts +++ b/apps/ui/src/features/deploy/task/runner.ts @@ -134,6 +134,7 @@ import { attachManagedDeploymentTimelineSuccess } from "./managed-timeline"; import { deployOutputProgressSummary } from "./output-progress"; import { deploymentTaskSourceProduct } from "./projection"; import { + accessEndpointPortLabelForUrl, type DeploymentResultApCandidate, deploymentResultApCandidates, isResultReadinessTerminalError, @@ -182,6 +183,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 +193,7 @@ import { attachDeploymentTaskSuccess, DEPLOYMENT_TASK_TERMINAL_FAILURE_EVENT_KEY, type DeploymentResultResourceCard, + type DeploymentTemplateEntryUrls, deploymentTaskSuccessFromTimeline, deploymentTimelineFailureStepId, deploymentTimelineResultReadinessReached, @@ -613,6 +616,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 +712,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 +2639,7 @@ async function completeTaskWithArtifact(input: { taskDeadlineAtMs, }); let templatePublicAccessCards: DeploymentResultResourceCard[] = []; + let templateEntries = applied.templateEntries; if (applied.templateProviderResources !== undefined) { const discoverySignal = deploymentOperationSignal({ deadlineAtMs: readinessDeadlineAtMs, @@ -2645,6 +2654,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, @@ -2704,18 +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 @@ -2733,6 +2768,38 @@ async function completeTaskWithArtifact(input: { }); } +/** + * What the provider-path entry read-back needs from the artifact: the + * template, the instance, and this run's args. An already-created + * `template-instance` artifact carries no args — they are never persisted + * (ADR 0037) — so it passes none, and the read-back drops every entry that + * substitutes an input rather than render it from a default the user may + * have overridden. + */ +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 { + 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 00000000..b4ce4eea --- /dev/null +++ b/apps/ui/src/features/deploy/task/template-provider-entries.test.ts @@ -0,0 +1,362 @@ +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>, + options: { patchStatus?: number } = {} +) { + 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( + 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 ?? ""]; + 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>; +} + +// 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: "StatefulSet", uid: "1" }, + { name: "eaglercraft", resourceType: "ingress", uid: "3" }, + { 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, + 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(providerSourceResponse(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("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()); + + 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 new file mode 100644 index 00000000..66378734 --- /dev/null +++ b/apps/ui/src/features/deploy/task/template-provider-entries.ts @@ -0,0 +1,320 @@ +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 TemplateDeclaredEntries, + type TemplateEntryUrls, + templateAppUrlFromDocs, + templateDeclaredEntries, + templateEntryOpenPort, + templateIngressHostsFromDocs, + templateIngressServiceNamesFromDocs, + 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; + } + // 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; + 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. + */ +/** 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: { + /** + * 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; + 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, + signal: input.signal, + templateName: input.templateName, + }); + const declared = renderableDeclaredEntries( + templateDeclaredEntries(source.templateYaml), + input.args + ); + const appNames = new Set([ + ...resourceNames(input.resources, APP_RESOURCE_TYPES), + input.instanceName, + ]); + const [ingresses, apps, instance] = await Promise.all([ + getObjects({ + ...read, + kind: "ingresses", + names: resourceNames(input.resources, INGRESS_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 + ? {} + : 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) { + // 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 + ? 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 d4504cc9..a68ffeed 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,319 @@ 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(input: { adminObserved: boolean }) { + 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}/`, + }), + ...(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, { + card, + stepId: "create-resources", + updatedAt: NOW, + }); + } + return timeline; +} + +test("a template's Open Entry leads the record and its Share Entry is the share address, not an entry", () => { + const success = deploymentTaskSuccessFromTimeline( + 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", + } + ); + // 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); + // 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", () => { + const success = deploymentTaskSuccessFromTimeline( + eaglercraftTimeline({ adminObserved: false }), + { + primaryEntryUrl: `wss://${EAGLER_HOST}/`, + productName: "EaglerCraft Server", + templateEntries: { open: EAGLER_OPEN }, + templateOpenEntryLabel: "admin · 5201", + } + ); + 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); + + // No AP observed the address: the entry is headed by nothing, not invented. + const unnamed = deploymentTaskSuccessFromTimeline( + eaglercraftTimeline({ adminObserved: false }), + { + 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: EAGLER_OPEN, share: `wss://${EAGLER_HOST}/` }, + } + ); + 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", () => { + 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, + }) + ); +}); + +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 5c79ab7d..1059e8b3 100644 --- a/apps/ui/src/features/deploy/task/timeline.ts +++ b/apps/ui/src/features/deploy/task/timeline.ts @@ -28,6 +28,16 @@ export type DeploymentAccessEndpointObserver = | { kind: "declared" } | { kind: "ingress"; name: string }; +/** + * 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; +} + export type DeploymentTimelineEventSeverity = | "info" | "success" @@ -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 + * 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. + */ + shareUrl?: string; verification?: DeploymentTaskSuccessVerification; verifiedAt: string; } @@ -708,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) { @@ -733,6 +756,15 @@ function accessProtocol( } } +/** + * 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); + return url != null && isShareableEntryUrl(url) ? url : undefined; +} + function successCount(value: unknown, max: number): number | undefined { return Number.isSafeInteger(value) && (value as number) >= 0 && @@ -853,6 +885,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 +898,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 +926,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, @@ -957,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 @@ -987,11 +1061,55 @@ export function prioritizeSuccessEntries( return [primary, ...entries.filter((_, position) => position !== index)]; } +/** + * 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 canonical + * URL, so `https://host` and `https://host/` are one address). + */ +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 }; +} + +/** + * 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) => isShareableEntryUrl(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), 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)) { @@ -1018,15 +1136,31 @@ export function deploymentTaskSuccessFromTimeline( return []; } }); + const openEntry = templateOpenEntry( + 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.some((entry) => sameEntryUrl(entry.url, openEntry.url)) + ? endpointEntries + : [openEntry, ...endpointEntries]; const uniqueEntries = prioritizeSuccessEntries( - endpointEntries.filter( + declaredEntries.filter( (entry, index) => - endpointEntries.findIndex( - (candidate) => candidate.url === entry.url + declaredEntries.findIndex((candidate) => + sameEntryUrl(candidate.url, entry.url) ) === index ), - input.primaryEntryUrl + openEntry?.url ?? input.primaryEntryUrl ); + const declaredShare = input.templateEntries?.share; + const shareUrl = + declaredShare != null && isShareableEntryUrl(declaredShare) + ? declaredShare + : deploymentTaskSuccessShareUrl({ entries: uniqueEntries }); const success = deploymentTaskSuccessFromResultReadiness({ productCategories: input.productCategories, productId: input.productId, @@ -1040,6 +1174,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 00000000..35bec70c --- /dev/null +++ b/apps/ui/src/features/deploy/template-entries.test.ts @@ -0,0 +1,462 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + applyTemplateEntries, + resolveTemplateEntryUrls, + stampTemplateEntryOpenPort, + templateAppUrlFromDocs, + templateDeclaredEntries, + templateEntryOpenPort, + templateEntryUrl, + templateIngressHostsFromDocs, + templateIngressServiceNamesFromDocs, + 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("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", () => { + 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("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( + 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 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` } + ); +}); + +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("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( + 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 00000000..ecb64ad1 --- /dev/null +++ b/apps/ui/src/features/deploy/template-entries.ts @@ -0,0 +1,481 @@ +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 + * 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, 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 { + 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, 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 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); + return { + ...(open === undefined ? {} : { open }), + ...(share === undefined ? {} : { share }), + }; +} + +/** 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) 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, + role: TemplateEntryRole +): 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 !== "" || + (role === "share" && 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; +} + +/** + * 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. + */ +export function resolveTemplateEntryUrls(input: { + appUrl?: string; + declared: TemplateDeclaredEntries; + hosts: ReadonlySet; +}): TemplateEntryUrls { + 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, "open") ?? served(input.appUrl, "open"); + const share = served(input.declared.share, "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 + * 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[]; + 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); + // 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 + ); + 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-inline-yaml.ts b/apps/ui/src/features/deploy/template-inline-yaml.ts new file mode 100644 index 00000000..3f3693f3 --- /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 359e95a8..98afa6e8 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.test.ts b/apps/ui/src/features/deploy/template-renderer.test.ts index 186013f5..153fa29d 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 4e6153c4..4623756d 100644 --- a/apps/ui/src/features/deploy/template-renderer.ts +++ b/apps/ui/src/features/deploy/template-renderer.ts @@ -14,6 +14,13 @@ import { LAUNCHPAD_APP_LABEL, LAUNCHPAD_TEMPLATE_SOURCE_LABEL, } from "@/lib/brain-labels"; +import { + applyTemplateEntries, + type TemplateDeclaredEntries, + type TemplateEntryUrls, + templateDeclaredEntries, +} from "./template-entries"; +import { templateHeaderFromInlineYaml } from "./template-inline-yaml"; import type { TemplateDefaultValue, TemplateSourceInput, @@ -72,6 +79,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[]; @@ -1620,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; @@ -1804,11 +1791,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 +1820,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 d7da1e64..e7bec5b4 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"; 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 79e56be7..df614bb5 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). 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 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 cb936a07..0c5b168b 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 00000000..a9516aac --- /dev/null +++ b/docs/adr/0081-declare-template-entries-for-open-and-share.md @@ -0,0 +1,156 @@ +# 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 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 +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 — 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 +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 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 +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. 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 — 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 +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 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 +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. +- **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 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 + 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. + +## 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 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. +- 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 f43e6d6d..de05b4de 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) *(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