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
8 changes: 6 additions & 2 deletions bin/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ if [[ -z "${ZEPPELIN_WAR}" ]]; then
if [[ -d "${ZEPPELIN_HOME}/zeppelin-web/dist" ]]; then
export ZEPPELIN_WAR="${ZEPPELIN_HOME}/zeppelin-web/dist"
else
ZEPPELIN_WAR=$(find -L "${ZEPPELIN_HOME}" -name "zeppelin-web-[0-9]*.war")
ZEPPELIN_WAR=$(find -L "${ZEPPELIN_HOME}" \
\( -name .git -o -name node -o -name node_modules \) -prune -o \
-name "zeppelin-web-[0-9]*.war" -print)
if [[ -n "${ZEPPELIN_WAR}" ]]; then
export ZEPPELIN_WAR
fi
Expand All @@ -56,7 +58,9 @@ if [[ -z "${ZEPPELIN_ANGULAR_WAR}" ]]; then
if [[ -d "${ZEPPELIN_HOME}/zeppelin-web-angular/dist/zeppelin" ]]; then
export ZEPPELIN_ANGULAR_WAR="${ZEPPELIN_HOME}/zeppelin-web-angular/dist/zeppelin"
else
ZEPPELIN_ANGULAR_WAR=$(find -L "${ZEPPELIN_HOME}" -name "zeppelin-web-angular*.war")
ZEPPELIN_ANGULAR_WAR=$(find -L "${ZEPPELIN_HOME}" \
\( -name .git -o -name node -o -name node_modules \) -prune -o \
-name "zeppelin-web-angular*.war" -print)
if [[ -n "${ZEPPELIN_ANGULAR_WAR}" ]]; then
export ZEPPELIN_ANGULAR_WAR
fi
Expand Down
85 changes: 81 additions & 4 deletions zeppelin-web-angular/e2e/core-contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,53 @@ server. It does not contact `PLAYWRIGHT_BASE_URL` or clean notebooks from anothe
The focused command runs Chromium. The ordinary E2E suite still includes the synthetic
browser tests in its Chromium, Firefox and WebKit projects and excludes `@live`.

The capture server requires `lsof` to verify listener ownership and a built checkout
(`./mvnw clean install -DskipTests -pl zeppelin-web-angular -am`). A startup failure
reports the server log; a successful HTTP response alone does not establish ownership.
The capture server requires `lsof` to verify listener ownership. Build a clean detached
`origin/master` checkout with
`./mvnw clean install -DskipTests -pl zeppelin-server,zeppelin-web-angular,shell -am`,
then create a build manifest before starting a committed-fixture capture. The manifest
hashes every server, frontend and interpreter output used by the launcher. Startup
recomputes those hashes and refuses a stale or modified build. Symlinked outputs must
resolve inside the build root. A startup failure reports the server log; a successful
HTTP response alone does not establish ownership.

A committed fixture is evidence of what the server on `origin/master` sends, so the
build must come from `origin/master` itself with no tracked changes: a fixture
captured from a branch would record that branch's behaviour as the contract. The
manifest refuses a checkout whose `HEAD` is not the local `origin/master` ref; fetch
first, because the ref is not compared with the remote. A pull request that changes
server behaviour therefore cannot capture fixtures for its own change. Recapture
them from `origin/master` after that change is merged.

`fixtures/build-manifest.json` is the manifest, as written by
`capture-build-manifest.mjs create`, of the build the committed execution fixtures
were captured from. `npm run check:core-contract-fixtures` requires every committed
execution fixture to embed that same manifest, so a recapture from a different build
replaces this file in the same change.

Committed live captures also identify the environment that produced them. Set
`ZEPPELIN_E2E_SOURCE_COMMIT` to the exact checkout commit used to run the capture,
`ZEPPELIN_E2E_BASE_COMMIT` to its `origin/master` commit,
`ZEPPELIN_E2E_BUILD_MANIFEST` to the manifest used to start the server, and
`ZEPPELIN_E2E_CAPTURE_ROOT` to the server's capture root. On start,
`capture-server.sh` writes the settings it actually launched with to
`capture-provenance.json` in that root: authentication mode, port, streaming
setting, isolated directories and the verified build-manifest id. The capture reads
that file rather than the test environment, fails if it is missing or belongs to
another root, fails if its manifest id differs from `ZEPPELIN_E2E_BUILD_MANIFEST`,
and fails if its port or streaming setting does not match the browser origin or
`ZEPPELIN_CAPTURE_EXPECT_STREAMING`. The fixture records both commits, the
build-manifest identity and relative artifact hashes, the browser name and version,
the explicit loopback origin and port, the `execution` capture mode, authentication
mode, interpreter configuration, and the isolated notebook, search index, log, pid,
and recovery directories. Directory values are sanitized relative to
`<capture-root>` before the fixture is committed. Validation rejects a live capture
when any of this provenance is missing. The committed manifest data contains no
checkout path.

Captured execution fixtures are written only when `ZEPPELIN_WRITE_EXECUTION_FIXTURES=1`.
They go to `e2e/core-contract/fixtures` unless `ZEPPELIN_E2E_FIXTURE_OUTPUT_DIR`
names another directory.

The capture root and repository paths must not contain whitespace, including in
physical paths reached through symlinks. The Zeppelin launcher splits JVM arguments
on whitespace; the capture script rejects these paths before creating files or
Expand Down Expand Up @@ -206,13 +250,46 @@ still select the installed toolchain.

```bash
CAPTURE_ROOT="$(mktemp -d)"
e2e/core-contract/capture-server.sh start --root "${CAPTURE_ROOT}" --port 18080
BUILD_ROOT=/path/to/clean-origin-master-checkout
BUILD_MANIFEST="$(mktemp)"
node e2e/core-contract/capture-build-manifest.mjs create "${BUILD_MANIFEST}" "${BUILD_ROOT}"
e2e/core-contract/capture-server.sh start --root "${CAPTURE_ROOT}" --port 18080 \
--build-root "${BUILD_ROOT}" --build-manifest "${BUILD_MANIFEST}"
ZEPPELIN_E2E_SHIRO_INI="${CAPTURE_ROOT}/conf/shiro.ini" \
ZEPPELIN_E2E_BUILD_MANIFEST="${BUILD_MANIFEST}" \
ZEPPELIN_E2E_SOURCE_COMMIT="$(git -C "${BUILD_ROOT}" rev-parse HEAD)" \
ZEPPELIN_E2E_BASE_COMMIT="$(git -C "${BUILD_ROOT}" rev-parse origin/master)" \
ZEPPELIN_CORE_CONTRACT_RUN_DIR="${CAPTURE_ROOT}/browser" \
CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run e2e:core-contract:live
e2e/core-contract/capture-server.sh stop --root "${CAPTURE_ROOT}"
```

Execution fixtures use a named `sh` interpreter and pin the server-side streaming
switch explicitly. Start one isolated server for each value; do not rewrite the
setting in a running server:

```bash
e2e/core-contract/capture-server.sh start --root "${CAPTURE_ROOT}" --port 18081 \
--paragraph-status-progress true --build-root "${BUILD_ROOT}" --build-manifest "${BUILD_MANIFEST}"
ZEPPELIN_CAPTURE_EXPECT_STREAMING=true \
ZEPPELIN_WRITE_EXECUTION_FIXTURES=1 \
ZEPPELIN_E2E_CAPTURE_ROOT="${CAPTURE_ROOT}" \
ZEPPELIN_E2E_SHIRO_INI="${CAPTURE_ROOT}/conf/shiro.ini" \
ZEPPELIN_E2E_BUILD_MANIFEST="${BUILD_MANIFEST}" \
ZEPPELIN_E2E_SOURCE_COMMIT="$(git -C "${BUILD_ROOT}" rev-parse HEAD)" \
ZEPPELIN_E2E_BASE_COMMIT="$(git -C "${BUILD_ROOT}" rev-parse origin/master)" \
ZEPPELIN_CORE_CONTRACT_RUN_DIR="${CAPTURE_ROOT}/browser" \
CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:18081 npm run e2e:core-contract:live
e2e/core-contract/capture-server.sh stop --root "${CAPTURE_ROOT}"
```

Repeat with a new capture root, port 18082, `--paragraph-status-progress false`, and
`ZEPPELIN_CAPTURE_EXPECT_STREAMING=false` for the disabled fixture. The committed
fixtures were captured on these ports, and their recorded origin shows which server
produced each one. The live
execution scenario skips with a named missing-interpreter reason if `sh` is not
installed; that result is not evidence that the fixture scenario passed.

For authenticated capture, add `--mode auth` to start. That installs
`shiro.ini.template` in the capture root; the same `ZEPPELIN_E2E_SHIRO_INI` setting
selects it. The helper wiring and a successful authenticated capture are separate
Expand Down
226 changes: 226 additions & 0 deletions zeppelin-web-angular/e2e/core-contract/capture-build-manifest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { createHash } from 'node:crypto';
import {
existsSync,
lstatSync,
readlinkSync,
readdirSync,
readFileSync,
realpathSync,
statSync,
writeFileSync
} from 'node:fs';
import { resolve, sep } from 'node:path';
import { execFileSync } from 'node:child_process';

const artifactInputs = [
{ path: '.', selection: 'root-jars' },
{ path: '.', selection: 'all-wars' },
{ path: 'bin' },
{ path: 'conf' },
{ path: 'lib', required: false },
{ path: 'lib/interpreter', required: false },
{ path: 'zeppelin-interpreter/target/classes' },
{ path: 'zeppelin-server/target/classes' },
{ path: 'zeppelin-interpreter/target/lib', required: false },
{ path: 'zeppelin-server/target/lib' },
{ path: 'zeppelin-web/target/lib', required: false },
{ path: 'zeppelin-web/dist', required: false },
{ path: 'zeppelin-web-angular/target/lib', required: false },
{ path: 'zeppelin-server/target/test-classes' },
{ path: 'interpreter' },
{ path: 'zeppelin-interpreter-shaded/target' },
{ path: 'zeppelin-web-angular/dist/zeppelin' },
{ path: 'shell/target/classes' }
];
// Repository-wide WAR searches skip VCS metadata and frontend toolchains; they never hold launch artifacts.
const skippedSearchDirectories = new Set(['.git', 'node', 'node_modules']);
const license =
'Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. ' +
'See the NOTICE file distributed with this work for additional information regarding copyright ownership. ' +
'The ASF licenses this file to You under the Apache License, Version 2.0.';

const hashInput = (root, input) => {
const { path: relativeRoot, required = true, selection = 'tree' } = input;
const absoluteRoot = resolve(root, relativeRoot);
const rootStat = statSync(absoluteRoot, { throwIfNoEntry: false });
if (!rootStat?.isDirectory()) {
if (required) throw new Error(`required build output is missing: ${relativeRoot}`);
const digest = createHash('sha256').update('missing\0').digest('hex');
return { exists: false, fileCount: 0, path: relativeRoot, selection, sha256: digest };
}
const realBuildRoot = realpathSync(root);
const assertInsideBuildRoot = (absolutePath, logicalPath) => {
const target = realpathSync(absolutePath);
if (target !== realBuildRoot && !target.startsWith(`${realBuildRoot}${sep}`)) {
throw new Error(`build output resolves outside the build root: ${logicalPath} -> ${target}`);
}
};
assertInsideBuildRoot(absoluteRoot, relativeRoot);
const files = [];
const activeDirectories = new Set();
const visit = (directory, logicalDirectory = '') => {
const realDirectory = realpathSync(directory);
if (activeDirectories.has(realDirectory)) throw new Error(`build output contains a symlink cycle: ${relativeRoot}`);
activeDirectories.add(realDirectory);
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
if (selection === 'root-jars' && logicalDirectory === '' && !entry.name.toLowerCase().endsWith('.jar')) continue;
if (selection === 'all-wars' && skippedSearchDirectories.has(entry.name)) continue;
const absolutePath = resolve(directory, entry.name);
const logicalPath = logicalDirectory ? `${logicalDirectory}/${entry.name}` : entry.name;
const linkStat = lstatSync(absolutePath);
if (linkStat.isSymbolicLink()) {
assertInsideBuildRoot(absolutePath, relativeRoot === '.' ? logicalPath : `${relativeRoot}/${logicalPath}`);
}
const targetStat = statSync(absolutePath);
if (targetStat.isDirectory()) {
if (selection === 'root-jars') continue;
visit(absolutePath, logicalPath);
} else if (targetStat.isFile()) {
if (selection === 'all-wars' && !entry.name.toLowerCase().endsWith('.war')) continue;
files.push({ absolutePath, logicalPath, symlink: linkStat.isSymbolicLink() });
} else {
throw new Error(`unsupported build output entry: ${relativeRoot}/${logicalPath}`);
}
}
activeDirectories.delete(realDirectory);
};
visit(absoluteRoot);
const digest = createHash('sha256');
for (const file of files) {
digest.update(file.logicalPath);
digest.update('\0');
digest.update(file.symlink ? 'symlink\0' : 'file\0');
if (file.symlink) {
digest.update(readlinkSync(file.absolutePath));
digest.update('\0');
}
digest.update(readFileSync(file.absolutePath));
digest.update('\0');
}
return { exists: true, fileCount: files.length, path: relativeRoot, selection, sha256: digest.digest('hex') };
};

const git = (root, ...args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8' }).trim();
const sortValue = value => {
if (Array.isArray(value)) return value.map(sortValue);
if (value && typeof value === 'object')
return Object.fromEntries(
Object.keys(value)
.sort()
.map(key => [key, sortValue(value[key])])
);
return value;
};
const canonical = value => `${JSON.stringify(sortValue(value), null, 2)}\n`;
const withId = body => ({
_license: license,
...body,
manifestId: createHash('sha256').update(canonical(body)).digest('hex')
});

const hashTrackedSource = root => {
const output = execFileSync('git', ['-C', root, 'ls-tree', '-rz', '--full-tree', 'HEAD'], { encoding: 'buffer' });
const digest = createHash('sha256');
let count = 0;
for (const entry of output.toString('utf8').split('\0').filter(Boolean)) {
const tab = entry.indexOf('\t');
const [mode, type] = entry.slice(0, tab).split(' ');
if (type !== 'blob') continue;
const path = entry.slice(tab + 1);
const absolutePath = resolve(root, path);
const stat = lstatSync(absolutePath, { throwIfNoEntry: false });
if (!stat) throw new Error(`tracked source is missing: ${path}`);
const content = stat.isSymbolicLink() ? Buffer.from(readlinkSync(absolutePath)) : readFileSync(absolutePath);
const actual = createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex');
const expected = entry.slice(0, tab).split(' ')[2];
if (actual !== expected) throw new Error(`tracked source differs from HEAD: ${path}`);
digest.update(mode).update('\0').update(path).update('\0').update(content).update('\0');
count += 1;
}
return { fileCount: count, sha256: digest.digest('hex') };
};

const findMatchingFiles = (root, pattern) => {
const matches = [];
const active = new Set();
const visit = (directory, logical = '') => {
const real = realpathSync(directory);
if (active.has(real)) throw new Error('launcher fallback search contains a symlink cycle');
active.add(real);
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
if (skippedSearchDirectories.has(entry.name)) continue;
const absolute = resolve(directory, entry.name);
const relative = logical ? `${logical}/${entry.name}` : entry.name;
const target = statSync(absolute);
if (target.isDirectory()) visit(absolute, relative);
else if (target.isFile() && pattern.test(entry.name)) matches.push(relative);
}
active.delete(real);
};
visit(root);
return matches;
};

const launchTargets = root => {
const select = (directory, pattern, label) => {
if (existsSync(resolve(root, directory))) return { kind: 'directory', path: directory };
const candidates = findMatchingFiles(root, pattern);
if (candidates.length > 1) throw new Error(`${label} fallback is ambiguous: ${candidates.join(', ')}`);
return candidates.length === 1 ? { kind: 'war', path: candidates[0] } : { kind: 'missing', path: '' };
};
return {
angularWeb: select('zeppelin-web-angular/dist/zeppelin', /^zeppelin-web-angular.*\.war$/, 'Angular WAR'),
classicWeb: select('zeppelin-web/dist', /^zeppelin-web-[0-9].*\.war$/, 'classic WAR')
};
};
const createManifest = root => {
const sourceCommit = git(root, 'rev-parse', 'HEAD');
const baseCommit = git(root, 'rev-parse', 'origin/master');
if (sourceCommit !== baseCommit)
throw new Error(`build checkout ${sourceCommit} must equal origin/master ${baseCommit}`);
if (git(root, 'status', '--porcelain', '--untracked-files=no')) throw new Error('build checkout has tracked changes');
return withId({
artifacts: artifactInputs.map(input => hashInput(root, input)),
baseCommit,
launchTargets: launchTargets(root),
sourceCommit,
sourceTree: hashTrackedSource(root),
version: 3
});
};

const [command, manifestPath, rootArgument] = process.argv.slice(2);
if (!['create', 'id', 'verify'].includes(command) || !manifestPath || (command !== 'id' && !rootArgument)) {
throw new Error('usage: capture-build-manifest.mjs create|verify <manifest> <build-root> | id <manifest>');
}
if (command === 'create') {
const manifest = createManifest(resolve(rootArgument));
writeFileSync(resolve(manifestPath), canonical(manifest));
} else {
const manifest = JSON.parse(readFileSync(resolve(manifestPath), 'utf8'));
if (command === 'id') {
process.stdout.write(`${manifest.manifestId}\n`);
} else {
const expected = createManifest(resolve(rootArgument));
if (canonical(manifest) !== canonical(expected)) {
throw new Error('build manifest does not match the current source and launched artifacts');
}
}
}
Loading
Loading