diff --git a/example.env b/example.env index 7ee0eb8..1353673 100644 --- a/example.env +++ b/example.env @@ -8,15 +8,6 @@ PROVISIONER_URL=https://example.com GUACAMOLE_CONNECTION_URL= APP_ENVIRONMENT= -# Optional. Oldest GlueOps/codespaces image release whose baked dev() reads -# /etc/glueops/tunnel_endpoint. Unset = regional tunnel endpoints disabled: -# every CDE VM uses the legacy central tunnel regardless of region config. -# Set this (e.g. v0.155.0) when enabling per-region tunnel_endpoint values in -# the provisioner's BAREMETAL_SERVER_CONFIGS; VMs created from older images -# still fall back to the central tunnel so their access URLs stay correct. -# Prerelease tags (vX.Y.Z-RC1) are supported on both sides: a stable release -# outranks its own RCs, so min=v0.155.0-RC1 admits the RCs and the stable cut. -REGIONAL_TUNNEL_MIN_IMAGE_TAG= # VM profiles store (required). The app fails to start if any of these are missing. # Must be a full URL including the scheme (https://). A bare host is rejected at startup. diff --git a/listeners/commands/vm.js b/listeners/commands/vm.js index b554389..b9c34dd 100644 --- a/listeners/commands/vm.js +++ b/listeners/commands/vm.js @@ -1,4 +1,4 @@ -import libvirt, { DEFAULT_TUNNEL_ENDPOINT, cdeAccessUrl } from '../../util/libvirt/libvirt-server.js'; +import libvirt, { cdeAccessUrl, RETIRED_CENTRAL_TUNNEL } from '../../util/libvirt/libvirt-server.js'; import vmCreateModal from '../../user-interface/modals/vm-create.js'; import vmProfileModal from '../../user-interface/modals/vm-profile.js'; import buttonBuilder from '../../util/button-builder.js'; @@ -259,11 +259,12 @@ export default { // Build header text with optional CDE URL let headerText = `Server: ${server.serverName}\nRegion: ${server.region}\nDescription: ${description}\nStatus: ${server.status}\nCreated: ${createdDate}\nRepo: ${cloneRepo || 'None'}`; - if (cdeToken) { - // The tunnel_endpoint tag records which sish host this VM's - // tunnel actually connects to; VMs created before regional - // tunnels have no tag and live on the legacy central endpoint. - const tunnelHost = server.tags.tunnel_endpoint || DEFAULT_TUNNEL_ENDPOINT; + // Every CDE VM records where its tunnel connects. A VM with no tag + // predates regional tunnels, and one still tagged with the retired + // central host binds under a cde- prefix there — neither has a URL + // this bot can build, so show the VM without an access link. + const tunnelHost = server.tags.tunnel_endpoint; + if (cdeToken && tunnelHost && tunnelHost !== RETIRED_CENTRAL_TUNNEL) { const cdeUrl = cdeAccessUrl(server.serverName, tunnelHost, cdeToken); headerText += `\nAccess: <${cdeUrl}|Cloud Development Environment>`; } diff --git a/util/get-user-data.js b/util/get-user-data.js index 8b8595d..2788f45 100644 --- a/util/get-user-data.js +++ b/util/get-user-data.js @@ -29,11 +29,14 @@ export default function configUserData(serverName, cdeToken = null, cdeEnv = {}, // Regional sish endpoint, world-readable like cde_token because the // host-side dev() (developer-setup.sh) reads it as the vscode user — - // codespace.env is root-only so it can't serve this. Absent file -> - // dev() falls back to the legacy central tunnel, which is also why the - // hostname is re-validated here (defence-in-depth; the resolver - // already enforced it): a value that can't round-trip safely through - // this runcmd is dropped rather than escaped. + // codespace.env is root-only so it can't serve this. There is no + // fallback any more: without this file dev() refuses to start the + // tunnel, so the resolver in libvirt-server.js must have accepted the + // value before it gets here. The pattern is re-checked (it must stay + // byte-identical to the resolver's) purely so a value that could not + // round-trip safely through this runcmd is dropped rather than + // escaped — reaching that branch means the resolver let something + // through it should not have. const tunnelEndpoint = cdeEnv?.TUNNEL_ENDPOINT; if (tunnelEndpoint && TUNNEL_ENDPOINT_PATTERN.test(tunnelEndpoint)) { userData += ` diff --git a/util/libvirt/libvirt-server.js b/util/libvirt/libvirt-server.js index bba736f..161ad20 100644 --- a/util/libvirt/libvirt-server.js +++ b/util/libvirt/libvirt-server.js @@ -21,97 +21,53 @@ const provisionerDetail = (error) => { : ''; }; -// Legacy central sish endpoint. Every VM created before regional tunnels -// existed connects here, so it is the fallback whenever a region doesn't -// declare its own tunnel_endpoint (or the lookup fails outright). -export const DEFAULT_TUNNEL_ENDPOINT = 'tunnels.glueopshosted.com'; - -// Access URL for a CDE VM. The legacy central sish appends the SSH username -// to a "cde" bind (cde-.tunnels...); regional instances let the VM -// bind its bare hostname (..tunnels.cde...). The VM-side rule -// in the codespaces image's developer-setup.sh derives the bind from the -// same endpoint value, so URL and tunnel can never disagree. -export const cdeAccessUrl = (serverName, tunnelEndpoint, cdeToken) => { - const host = tunnelEndpoint === DEFAULT_TUNNEL_ENDPOINT - ? `cde-${serverName}.${tunnelEndpoint}` - : `${serverName}.${tunnelEndpoint}`; - return `https://${host}?folder=/workspaces/glueops&tkn=${cdeToken}`; -}; +// The retired central tunnel. VMs there bind under a "cde-" prefix, so any +// region still pointing at it would advertise a URL nothing serves — reject +// it where the value is minted, not just where it is rendered. +export const RETIRED_CENTRAL_TUNNEL = 'tunnels.glueopshosted.com'; + +// Access URL for a CDE VM: the VM binds its bare hostname at its region's +// sish endpoint, so the URL is ..tunnels.cde... The VM-side +// bind in the codespaces image's developer-setup.sh matches, so URL and +// tunnel cannot disagree. +export const cdeAccessUrl = (serverName, tunnelEndpoint, cdeToken) => + `https://${serverName}.${tunnelEndpoint}?folder=/workspaces/glueops&tkn=${cdeToken}`; // Resolve the sish endpoint for a region from the provisioner's region -// config. Never throws: creation must not fail (or silently go central-only -// with a regional URL) because of a transient /v1/regions error. A value that -// fails the hostname pattern is rejected here, before it fans out to the -// permanent tag, the access URLs, and cloud-init — those consumers must all -// agree on one endpoint, and cloud-init would silently drop a non-hostname. +// config. Throws if the region has no usable endpoint: with no central +// tunnel left to fall back to, a VM without a real endpoint would advertise +// a dead URL, so failing the creation loudly is the only honest outcome. const getTunnelEndpoint = async (region) => { + let res; try { - const res = await axios.get(`${process.env.PROVISIONER_URL}/v1/regions`, { + res = await axios.get(`${process.env.PROVISIONER_URL}/v1/regions`, { headers: { 'Authorization': `${process.env.PROVISIONER_API_TOKEN}` }, timeout: 1000 * 30 }); - // Normalize case and any trailing dot: DNS treats them as equivalent, - // but the legacy-vs-regional split in cdeAccessUrl and on the VM is an - // exact string comparison against DEFAULT_TUNNEL_ENDPOINT. - const endpoint = res.data?.find(r => r.region_name === region) - ?.tunnel_endpoint?.trim().toLowerCase().replace(/\.$/, ''); - if (!endpoint) return DEFAULT_TUNNEL_ENDPOINT; - if (!TUNNEL_ENDPOINT_PATTERN.test(endpoint)) { - log.error(`Region ${region} has invalid tunnel_endpoint "${endpoint}", using default`); - return DEFAULT_TUNNEL_ENDPOINT; - } - return endpoint; } catch (error) { - log.error('Failed to resolve tunnel endpoint, using default', axiosError(error)); - return DEFAULT_TUNNEL_ENDPOINT; - } -}; - -// Parse a codespaces release tag into its numeric core and optional -// prerelease suffix. Accepts the repo's real tag shapes: vX.Y.Z and -// prereleases like vX.Y.Z-RC1 (nonprod's image picker serves those). The -// strict shape check matters: Number('') is 0, not NaN, so a typo like "v" -// would otherwise parse as [0] and open the gate for every old image. -const parseImageTag = (tag) => { - const m = /^v?(\d+(?:\.\d+)*)(?:-([0-9a-z.-]+))?$/i.exec(String(tag).trim()); - if (!m) return null; - return { core: m[1].split('.').map(Number), pre: m[2]?.toLowerCase() ?? null }; -}; - -const compareImageTags = (a, b) => { - for (let i = 0; i < Math.max(a.core.length, b.core.length); i++) { - const d = (a.core[i] || 0) - (b.core[i] || 0); - if (d) return d; + // Only genuinely self-healing failures earn the retry message: no + // response at all (transport/timeout), a 5xx, or a 429. A 4xx is a + // real misconfiguration — a rotated PROVISIONER_API_TOKEN answers 401 + // — and must route to the escalate path instead of telling users to + // keep retrying something that will never succeed. The original error + // rides along so the logger can still record status and body. + const status = error.response?.status; + const transient = !error.response || status >= 500 || status === 429; + throw Object.assign(new Error(`Could not read the region list: ${error.message}`), { transient, cause: error }); } - // Equal numeric cores: a stable release outranks its own prereleases (an - // RC may predate fixes in the stable cut); prereleases compare naturally - // so RC2 < RC10. - if (!a.pre && !b.pre) return 0; - if (!a.pre) return 1; - if (!b.pre) return -1; - return a.pre.localeCompare(b.pre, undefined, { numeric: true }); -}; - -// Images older than REGIONAL_TUNNEL_MIN_IMAGE_TAG bake a dev() that ignores -// /etc/glueops/tunnel_endpoint and always tunnels to the legacy central -// endpoint, so giving such a VM a regional endpoint would advertise dead -// access URLs. Unset means regional tunnels stay off entirely; unparseable -// tags fail safe to legacy but are logged loudly, because a set-but-broken -// gate must not be indistinguishable from the feature being off. -const imageSupportsRegionalTunnel = (imageName) => { - const min = process.env.REGIONAL_TUNNEL_MIN_IMAGE_TAG; - if (!min) return false; - const floor = parseImageTag(min); - if (!floor) { - log.error(`REGIONAL_TUNNEL_MIN_IMAGE_TAG "${min}" is not a parseable release tag; regional tunnels stay OFF`); - return false; + const entry = res.data?.find(r => r.region_name === region); + if (!entry) { + // /v1/regions omits a region whose backend is briefly unreachable, so + // an absent entry is not evidence of a misconfiguration. + throw Object.assign(new Error(`Region ${region} is not currently listed`), { transient: true }); } - const image = parseImageTag(imageName); - if (!image) { - log.error(`Image tag "${imageName}" is not a parseable release tag; VM falls back to the legacy tunnel endpoint`); - return false; + // Normalize case and any trailing dot; DNS treats them as equivalent but + // the value becomes a permanent tag, a URL, and a cloud-init file. + const endpoint = entry.tunnel_endpoint?.trim().toLowerCase().replace(/\.$/, ''); + if (!endpoint || endpoint === RETIRED_CENTRAL_TUNNEL || !TUNNEL_ENDPOINT_PATTERN.test(endpoint)) { + throw new Error(`Region ${region} has no valid tunnel_endpoint (got ${JSON.stringify(endpoint)})`); } - return compareImageTags(image, floor) >= 0; + return endpoint; }; export default { @@ -125,16 +81,29 @@ export default { // Generate CDE token if Single-Click Experience is enabled const cdeToken = singleClickExperience ? generateCdeToken() : null; - // The sish tunnel only runs on CDE-enabled VMs, so only resolve the - // endpoint for those, and only when the chosen image can actually read - // it — otherwise the VM gets the legacy endpoint so its tag and URLs - // match where the tunnel really connects. Recorded in tags below as - // the permanent truth (the region config may change later). + // The sish tunnel only runs on CDE-enabled VMs, so only resolve for + // those. Recorded in tags below as the permanent truth of where this + // VM's tunnel connects (the region config may change later). A region + // with no usable endpoint fails the creation here rather than handing + // the user a VM whose access URL points nowhere. let tunnelEndpoint = null; if (cdeToken) { - tunnelEndpoint = imageSupportsRegionalTunnel(imageName) - ? await getTunnelEndpoint(region) - : DEFAULT_TUNNEL_ENDPOINT; + try { + tunnelEndpoint = await getTunnelEndpoint(region); + } catch (error) { + log.error('Failed to resolve tunnel endpoint', axiosError(error.cause ?? error)); + const reason = error.transient + ? `couldn't read the region list just now — please try again.` + : `no tunnel endpoint configured for region ${region}. Please report this to the platform team.`; + if (!batch) { + await client.chat.postEphemeral({ + channel: channel_id, + user: body.user.id, + text: `Failed to create server: ${reason}` + }); + } + return { success: false, serverName, description: description || 'No description' }; + } } // Call the users.info method using the WebClient