UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

338 lines (337 loc) • 14.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.workspaceDataDirectory = exports.cacheDir = void 0; exports.readCacheDirectoryProperty = readCacheDirectoryProperty; exports.resetSharedRootCacheForTesting = resetSharedRootCacheForTesting; exports.resolveSharedDataLocation = resolveSharedDataLocation; exports.sharedUserDataDir = sharedUserDataDir; exports.sharedDataDirectory = sharedDataDirectory; exports.cacheDirectoryForWorkspace = cacheDirectoryForWorkspace; exports.workspaceDataDirectoryForWorkspace = workspaceDataDirectoryForWorkspace; const crypto_1 = require("crypto"); const fs_1 = require("fs"); const path_1 = require("path"); const nx_json_1 = require("../config/nx-json"); const nx_tmp_dir_1 = require("./nx-tmp-dir"); const owned_private_dir_1 = require("./owned-private-dir"); const workspace_id_1 = require("./workspace-id"); const workspace_root_1 = require("./workspace-root"); function absolutePath(root, path) { if ((0, path_1.isAbsolute)(path)) { return path; } else { return (0, path_1.join)(root, path); } } function cacheDirectory(root, cacheDirectory) { const cacheDirFromEnv = process.env.NX_CACHE_DIRECTORY; if (cacheDirFromEnv) { cacheDirectory = cacheDirFromEnv; } if (cacheDirectory) { return absolutePath(root, cacheDirectory); } else { return defaultCacheDirectory(root); } } function pickCacheDirectory(root, nonNxCacheDirectory, nxCacheDirectory) { // If nx.json doesn't exist the repo can't utilize // caching, so .nx/cache is less relevant. Lerna users // that don't want to fully opt in to Nx at this time // may also be caught off guard by the appearance of // a .nx directory, so we are going to special case // this for the time being. if ((0, fs_1.existsSync)((0, path_1.join)(root, 'lerna.json')) && !(0, fs_1.existsSync)((0, path_1.join)(root, 'nx.json'))) { return (0, path_1.join)(root, 'node_modules', '.cache', nonNxCacheDirectory); } return (0, path_1.join)(root, '.nx', nxCacheDirectory); } function defaultCacheDirectory(root) { return pickCacheDirectory(root, 'nx', 'cache'); } function defaultWorkspaceDataDirectory(root) { return pickCacheDirectory(root, 'nx-workspace-data', 'workspace-data'); } /** * `readNxJson` rather than a raw read, so a `cacheDirectory` reached through * `extends` counts. It throws on malformed JSON, which must not take the * process down here: this runs at module scope, and a broken `nx.json` has a * better error waiting for it further in. */ function readCacheDirectoryProperty(root) { try { const nxJson = (0, nx_json_1.readNxJson)(root); return (nxJson.cacheDirectory ?? nxJson.tasksRunnerOptions?.default.options.cacheDirectory); } catch { return undefined; } } /** * The directory each kind gets under the shared root. `workspace-data` is the * checkout-local name and stays that way in a checkout; what moves to the shared * root is only the DB, so it is named for what it holds. */ const SHARED_SEGMENT = { cache: 'cache', 'workspace-data': 'databases', }; const SHARED_DATA_KINDS = Object.keys(SHARED_SEGMENT); /** * Whether `~/.nx` is somewhere other than the shared container. * * `homedir()` honours `$HOME`, so with `HOME=/tmp` the two are one path -- and * that container is deliberately world-writable (`1777`) so peers can each * create their own subtree under it. Build outputs and the graph DB get * replayed into the workspace and supply target commands, so they cannot live * anywhere a peer can replace them. * * `daemon/tmp-dir.ts` makes the same check for the socket tier. It cannot be * shared from there: that module imports this one. */ function homeDirIsDistinctFromSharedTmp() { if (!nx_tmp_dir_1.NX_HOME_TMP_DIR) { return false; } const home = (0, owned_private_dir_1.canonicalDir)(nx_tmp_dir_1.NX_HOME_TMP_DIR); return ![nx_tmp_dir_1.NX_TMP_DIR, nx_tmp_dir_1.NX_USER_TMP_DIR].some((shared) => (0, owned_private_dir_1.canonicalDir)(shared) === home); } /** Memoizes the verdict below, per repository. */ const sharedRootUsable = new Map(); /** The establish verdict is cached per process; tests need it cleared. */ function resetSharedRootCacheForTesting() { sharedRootUsable.clear(); workspaceIds.clear(); resolvedLocations.clear(); } /** * Establishes every directory the shared layout needs, owner-only. * * All of them together, because both kinds have to reach the same verdict: a * run whose cache relocated while its DB did not takes a cache hit on artifacts * it never wrote. One answer serves both. */ function establishUserRoot(workspaceId) { if (!nx_tmp_dir_1.NX_HOME_TMP_DIR || !homeDirIsDistinctFromSharedTmp()) { return { kind: 'not-a-directory', dir: nx_tmp_dir_1.NX_HOME_TMP_DIR ?? '~/.nx' }; } const repoRoot = (0, path_1.join)(nx_tmp_dir_1.NX_HOME_TMP_DIR, sharedDirName(workspaceId)); const required = [ nx_tmp_dir_1.NX_HOME_TMP_DIR, repoRoot, ...SHARED_DATA_KINDS.map((kind) => (0, path_1.join)(repoRoot, SHARED_SEGMENT[kind])), ]; for (const dir of required) { // Non-recursive and 0700 per level, with ownership re-checked -- a level // left at the ambient umask is what the next run refuses. const result = (0, owned_private_dir_1.ensureOwnedPrivateDir)(dir); if (result.status === 'refused') { return result.refusal; } } // Both leaves, not just the cache. A sandbox policy can grant one and deny // the other, and establishing them says nothing about that: the mkdir is a // no-op on a directory that already exists and is already ours. Probing only // the cache would send both kinds to the shared root and then EPERM on the // DB -- the failure this probe exists to prevent, one directory over. for (const kind of SHARED_DATA_KINDS) { const refusal = probeWritable((0, path_1.join)(repoRoot, SHARED_SEGMENT[kind])); if (refusal) { return refusal; } } return undefined; } /** * Whether this process can actually write under the shared root. * * `ensureOwnedPrivateDir` tolerates `EEXIST` and performs no write at all for a * 0700 directory we already own, so on every run after the first it establishes * nothing. A sandbox that denies writes under `~/.nx` reaches this point with * four no-ops behind it and would be told to put its cache there -- then EPERM * on every write, which is the failure the fallback exists to avoid. Writing is * what the caller is about to do, so writing is what gets tested. * * The marker name is random and the create is exclusive. `wx` is what stops a * symlink planted at this path being followed: the default `w` would truncate * whatever it points at, outside the granted root, and still report writable. * `~/.nx` is writable by a sandboxed agent sharing our uid, so no ownership * check fires on it. Randomness also keeps the anti-collision property the pid * gave, without making the name guessable. The marker is removed either way. */ function probeWritable(dir) { const marker = (0, path_1.join)(dir, `.nx-write-probe-${(0, crypto_1.randomBytes)(8).toString('hex')}`); try { (0, fs_1.writeFileSync)(marker, '', { flag: 'wx' }); return undefined; } catch (e) { return { kind: 'not-created', dir, code: e?.code }; } finally { try { (0, fs_1.unlinkSync)(marker); } catch { } } } /** Where this workspace can actually share, cached per identity. */ function shareableLocation(workspaceId) { const cached = sharedRootUsable.get(workspaceId); if (cached !== undefined) { return cached; } const location = establishUserRoot(workspaceId) ? { share: 'none' } : { share: 'user', dirName: sharedDirName(workspaceId) }; sharedRootUsable.set(workspaceId, location); return location; } /** * The outer segment, with the kind beneath it, so one workspace's shared data * is a single directory: `~/.nx/<id>/{cache,databases}`. * * Hashed rather than used raw. `generateWorkspaceId` returns the Nx Cloud * access token when that is the only identity available, and a token has no * business in a path that reaches `nx reset` output, error messages and CI * logs. Hashing costs nothing here: the input is already an identity, so * unlike a filesystem path it needs no canonicalizing or case folding. */ function sharedDirName(workspaceId) { return (0, crypto_1.createHash)('sha256') .update(workspaceId) .digest('hex') .substring(0, 16); } /** Memoized so the git calls behind the identity happen once per process. */ const workspaceIds = new Map(); /** * The verdict, frozen per root for the life of the process. * * `cacheDir` is a module-scope const, so the cache's answer is fixed at import. * Re-deciding on each call would let the DB reach a different one later in the * same process: `NX_CACHE_DIRECTORY` is not in `DAEMON_ENV_VARS_EXCLUSIONS` and * matches no prefix, so `applyDaemonEnvFromClient` can set it in the daemon * after startup. A first `getDbConnection()` after that would answer `none` * while `cacheDir` is already shared -- cache and DB in different scopes, which * is the torn state one predicate exists to prevent. Freezing it is what makes * "one decision" true over time and not just at a single call. */ const resolvedLocations = new Map(); /** * This workspace's identity, or null when it has none. * * The Nx Cloud id where there is one, else a key derived from the git remote * (or the first commit). Deliberately not a path: a checkout that is moved or * renamed keeps its identity, and every checkout of the workspace resolves to * the same one -- worktrees because a linked worktree is its own git top * level, separate clones because they share a remote. * * Null for anything with no derivable identity -- not a git repository, or a * shallow clone with no remote. Those keep their own copy. */ function workspaceIdFor(root) { if (!workspaceIds.has(root)) { let id = null; try { // `readNxJson` throws on malformed `nx.json`, which must not take the // process down: this runs at module scope and there is a better error // waiting further in. `generateWorkspaceId` swallows its own git // failures. id = (0, workspace_id_1.generateWorkspaceId)(root, (0, nx_json_1.readNxJson)(root)); } catch { } workspaceIds.set(root, id); } return workspaceIds.get(root); } /** * The one decision the cache and the workspace-data DB both follow. * * They have to move together. The DB's `cache_outputs` rows index the cache * directory's contents, so a checkout reading a shared DB while writing a * private cache takes a hit on a row whose artifacts it does not have -- and * `finalizeCacheHits` still calls that `local-cache`, with nothing restored. * Two predicates is how that happens, so there is only one. * * The per-user root is preferred for every checkout, not only for linked * worktrees, and an agent sandbox can be granted `~/.nx` by a committed * settings file where an absolute checkout path cannot (NXC-4625). */ function resolveSharedDataLocation(root) { let location = resolvedLocations.get(root); if (location === undefined) { location = computeSharedDataLocation(root); resolvedLocations.set(root, location); } return location; } function computeSharedDataLocation(root) { // Process-global, and pointing at one location for this run whichever // checkout asks. There is nothing left to share, so this is settled before // anything touches git or the filesystem. if (process.env.NX_CACHE_DIRECTORY || process.env.NX_WORKSPACE_DATA_DIRECTORY || process.env.NX_PROJECT_GRAPH_CACHE_DIRECTORY) { return { share: 'none' }; } // A configured location is the user's and is not ours to relocate. This // checkout's own configuration decides it; no other checkout is consulted. if (readCacheDirectoryProperty(root)) { return { share: 'none' }; } const workspaceId = workspaceIdFor(root); return workspaceId ? shareableLocation(workspaceId) : { share: 'none' }; } /** * The per-user directory a workspace's checkouts share for `kind`. * * It lives outside every checkout because an agent sandbox grants paths, and a * checkout's absolute path is different on each machine so it cannot be * committed to a shared settings file. This root can (NXC-4625). */ function sharedUserDataDir(dirName, kind) { return (0, path_1.join)(nx_tmp_dir_1.NX_HOME_TMP_DIR, dirName, SHARED_SEGMENT[kind]); } /** * The directory `kind` resolves to, following the one sharing decision. * * The shared answer is the same shape for both kinds. The unshared one is not: * the cache honours a configured `cacheDirectory` and the lerna special case, * the DB honours `NX_WORKSPACE_DATA_DIRECTORY`. */ function sharedDataDirectory(root, kind) { const location = resolveSharedDataLocation(root); if (location.share === 'user') { return sharedUserDataDir(location.dirName, kind); } return kind === 'cache' ? cacheDirectoryForWorkspace(root) : workspaceDataDirectoryForWorkspace(root); } /** * Path to the directory where Nx stores its cache. * * Normally the shared per-user directory, so every checkout of the workspace * uses one cache. A configured `cacheDirectory` is honored instead, resolved * against the root that asks -- so two checkouts share a configured location * only when the value is absolute. See `resolveSharedDataLocation`. */ exports.cacheDir = sharedDataDirectory(workspace_root_1.workspaceRoot, 'cache'); function cacheDirectoryForWorkspace(root) { return cacheDirectory(root, readCacheDirectoryProperty(root)); } /** * This checkout's own workspace-data directory, never the shared one. Daemon * logs and the `disabled` marker live here, and both describe this checkout. */ exports.workspaceDataDirectory = workspaceDataDirectoryForWorkspace(workspace_root_1.workspaceRoot); function workspaceDataDirectoryForWorkspace(workspaceRoot) { return absolutePath(workspaceRoot, process.env.NX_WORKSPACE_DATA_DIRECTORY ?? process.env.NX_PROJECT_GRAPH_CACHE_DIRECTORY ?? defaultWorkspaceDataDirectory(workspaceRoot)); }