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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,6 @@ PROVISIONER_URL=https://example.com
GUACAMOLE_CONNECTION_URL=<URL for guacamole connections>
APP_ENVIRONMENT=<nonprod | prod>

# 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=<codespaces release tag, e.g. v0.155.0 or v0.155.0-RC1>

# 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.
Expand Down
13 changes: 7 additions & 6 deletions listeners/commands/vm.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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>`;
}
Expand Down
13 changes: 8 additions & 5 deletions util/get-user-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 += `
Expand Down
145 changes: 57 additions & 88 deletions util/libvirt/libvirt-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>.tunnels...); regional instances let the VM
// bind its bare hostname (<name>.<region>.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 <name>.<region>.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 {
Expand All @@ -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
Expand Down
Loading