476 lines
19 KiB
JavaScript
476 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs";
|
|
import {
|
|
DEV_ENDPOINT,
|
|
DEV_FRONTEND_ENDPOINT,
|
|
ENVIRONMENT_DEV,
|
|
SERVICE_IDS
|
|
} from "../internal/protocol/index.mjs";
|
|
import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const fixturePath = path.join(repoRoot, "fixtures/dev-deploy-smoke/dev-deploy-smoke.json");
|
|
const defaultActiveReportPath = tempReportPath("dev-m2-deploy-smoke-active.json");
|
|
const legacyPublicEndpoint = "http://74.48.78.17:6667";
|
|
const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
const digestPattern = /^(sha256:[a-f0-9]{64}|not_applicable)$/;
|
|
const tagPattern = /^m2-dev-deploy-smoke-[a-f0-9]{7}$/;
|
|
const allowedFailureClassifications = new Set([
|
|
"network_blocker",
|
|
"runtime_blocker",
|
|
"agent_blocker",
|
|
"observability_blocker",
|
|
"safety_blocker"
|
|
]);
|
|
|
|
function parseArgs(argv) {
|
|
const flags = new Set();
|
|
const options = {
|
|
reportPath: defaultActiveReportPath,
|
|
writeReport: false,
|
|
timeoutMs: 5000
|
|
};
|
|
const rest = [];
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--report") {
|
|
flags.add(arg);
|
|
index += 1;
|
|
assert.ok(argv[index], "--report requires a path");
|
|
options.reportPath = argv[index];
|
|
options.writeReport = true;
|
|
} else if (arg === "--timeout-ms") {
|
|
flags.add(arg);
|
|
index += 1;
|
|
options.timeoutMs = Number.parseInt(argv[index], 10);
|
|
assert.ok(Number.isInteger(options.timeoutMs) && options.timeoutMs > 0, "--timeout-ms must be positive");
|
|
} else if (arg.startsWith("--")) {
|
|
flags.add(arg);
|
|
} else {
|
|
rest.push(arg);
|
|
}
|
|
}
|
|
return { flags, options, rest };
|
|
}
|
|
|
|
function loadFixture() {
|
|
return readFile(fixturePath, "utf8").then((raw) => JSON.parse(raw));
|
|
}
|
|
|
|
function assertTimestamp(value, context) {
|
|
assert.equal(typeof value, "string", `${context} must be a string`);
|
|
assert.match(value, timestampPattern, `${context} must be an RFC 3339 UTC timestamp`);
|
|
}
|
|
|
|
function assertServiceContract(entry) {
|
|
assert.ok(SERVICE_IDS.includes(entry.serviceId), `unknown serviceId ${entry.serviceId}`);
|
|
assert.equal(entry.deployEnv, ENVIRONMENT_DEV, `${entry.serviceId} deployEnv must be dev`);
|
|
assert.ok(!Object.hasOwn(entry, "endpoint"), `${entry.serviceId} service contract must not carry endpoint`);
|
|
assert.equal(typeof entry.commitId, "string", `${entry.serviceId} commitId must be a string`);
|
|
assert.match(entry.commitId, /^[a-f0-9]{7}$/u, `${entry.serviceId} commitId must be a short sha`);
|
|
assert.equal(typeof entry.image, "string", `${entry.serviceId} image must be a string`);
|
|
assert.ok(entry.image.length > 0, `${entry.serviceId} image must not be empty`);
|
|
assert.equal(typeof entry.tag, "string", `${entry.serviceId} tag must be a string`);
|
|
assert.match(entry.tag, tagPattern, `${entry.serviceId} tag must encode the smoke commit`);
|
|
assert.equal(typeof entry.digest, "string", `${entry.serviceId} digest must be a string`);
|
|
assert.match(entry.digest, digestPattern, `${entry.serviceId} digest must be sha256 or not_applicable`);
|
|
if (entry.digest === "not_applicable") {
|
|
assert.equal(typeof entry.digestReason, "string", `${entry.serviceId} digestReason required when digest is not_applicable`);
|
|
assert.ok(entry.digestReason.length > 0, `${entry.serviceId} digestReason must not be empty`);
|
|
} else {
|
|
assert.equal(entry.digestReason, undefined, `${entry.serviceId} digestReason must be absent when digest is present`);
|
|
}
|
|
assert.equal(typeof entry.buildSource, "string", `${entry.serviceId} buildSource must be a string`);
|
|
assert.ok(entry.buildSource.includes("origin/main"), `${entry.serviceId} buildSource must point at origin/main`);
|
|
assertTimestamp(entry.healthTimestamp, `${entry.serviceId} healthTimestamp`);
|
|
assert.equal(typeof entry.routePhase, "number", `${entry.serviceId} routePhase must be numeric`);
|
|
assert.ok(entry.routePhase > 0, `${entry.serviceId} routePhase must be positive`);
|
|
assert.ok(entry.phase.length > 0, `${entry.serviceId} phase must not be empty`);
|
|
assert.equal(typeof entry.health, "object", `${entry.serviceId} health must be an object`);
|
|
assert.ok(entry.health && !Array.isArray(entry.health), `${entry.serviceId} health must be a plain object`);
|
|
assert.ok(typeof entry.health.status === "string", `${entry.serviceId} health.status must be a string`);
|
|
assert.equal(typeof entry.failureClassification, "string", `${entry.serviceId} failureClassification must be a string`);
|
|
assert.ok(entry.failureClassification.endsWith("_blocker"), `${entry.serviceId} failureClassification must end in _blocker`);
|
|
assert.ok(
|
|
allowedFailureClassifications.has(entry.failureClassification),
|
|
`${entry.serviceId} failureClassification must be one of the frozen blocker classes`
|
|
);
|
|
}
|
|
|
|
function assertRoutePhaseOrder(entries) {
|
|
const phases = entries.map((entry) => entry.routePhase);
|
|
assert.deepEqual(phases, [...phases].sort((a, b) => a - b), "route phases must be sorted");
|
|
assert.equal(new Set(phases).size, phases.length, "route phases must be unique");
|
|
}
|
|
|
|
function assertAllServiceIds(entries) {
|
|
const serviceIds = entries.map((entry) => entry.serviceId);
|
|
assert.equal(new Set(serviceIds).size, serviceIds.length, "service ids must be unique in fixture");
|
|
for (const serviceId of serviceIds) {
|
|
assert.ok(SERVICE_IDS.includes(serviceId), `fixture uses unknown serviceId ${serviceId}`);
|
|
}
|
|
}
|
|
|
|
function assertCloudSurface(entry) {
|
|
const healthArtifacts = entry.health.artifacts ?? [];
|
|
assert.equal(entry.serviceId, "hwlab-cloud-api", "cloud surface entry must start at cloud-api");
|
|
assert.equal(entry.health.db?.fixtureOnly, true, "cloud surface DB note must be fixture-only");
|
|
assert.equal(entry.health.db?.liveDbEvidence, false, "cloud surface fixture must not claim live DB evidence");
|
|
assert.deepEqual(
|
|
entry.health.db?.missingEnv,
|
|
["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"],
|
|
"cloud surface fixture must name missing DB env without values"
|
|
);
|
|
assert.equal(healthArtifacts.length, 2, "cloud surface must include cloud-api and cloud-web artifacts");
|
|
assert.deepEqual(
|
|
healthArtifacts.map((artifact) => artifact.serviceId),
|
|
["hwlab-cloud-api", "hwlab-cloud-web"],
|
|
"cloud surface artifacts must list api/web"
|
|
);
|
|
for (const artifact of healthArtifacts) {
|
|
assert.ok(SERVICE_IDS.includes(artifact.serviceId), `cloud surface artifact ${artifact.serviceId} must be known`);
|
|
assert.equal(typeof artifact.image, "string", `cloud surface artifact ${artifact.serviceId} image must exist`);
|
|
assert.equal(typeof artifact.tag, "string", `cloud surface artifact ${artifact.serviceId} tag must exist`);
|
|
assert.match(artifact.tag, tagPattern, `cloud surface artifact ${artifact.serviceId} tag must encode the smoke commit`);
|
|
assert.match(artifact.digest, /^sha256:[a-f0-9]{64}$/u, `cloud surface artifact ${artifact.serviceId} digest must be sha256`);
|
|
assert.equal(typeof artifact.commitId, "string", `cloud surface artifact ${artifact.serviceId} commitId must exist`);
|
|
assert.equal(typeof artifact.buildSource, "string", `cloud surface artifact ${artifact.serviceId} buildSource must exist`);
|
|
assert.equal(artifact.deployEnv, ENVIRONMENT_DEV, `cloud surface artifact ${artifact.serviceId} deployEnv must be dev`);
|
|
assertTimestamp(artifact.healthTimestamp ?? entry.healthTimestamp, `cloud surface artifact ${artifact.serviceId} healthTimestamp`);
|
|
}
|
|
}
|
|
|
|
function assertFixture(fixture) {
|
|
assert.equal(fixture.smokeId, "m2-dev-deploy-smoke");
|
|
assert.equal(fixture.issue, "pikasTech/HWLAB#23");
|
|
assert.equal(fixture.environment, ENVIRONMENT_DEV);
|
|
assert.equal(fixture.endpoint, DEV_ENDPOINT);
|
|
assert.equal(fixture.frontendEndpoint, DEV_FRONTEND_ENDPOINT);
|
|
assert.equal(fixture.legacyPublicEndpoint?.endpoint, legacyPublicEndpoint);
|
|
assert.equal(fixture.legacyPublicEndpoint?.activeGreenEligible, false);
|
|
assert.equal(fixture.defaultMode, "dry-run");
|
|
assert.equal(fixture.safetyGates.dryRunOnly, true, "dry-run must be the default");
|
|
assert.ok(Array.isArray(fixture.safetyGates.realRequestsRequire), "real request gates must exist");
|
|
assert.ok(fixture.safetyGates.realRequestsRequire.includes("explicit --live flag"));
|
|
|
|
const entries = fixture.serviceContracts;
|
|
assert.ok(Array.isArray(entries) && entries.length >= 8, "serviceContracts must contain route observations");
|
|
assertAllServiceIds(entries);
|
|
assertRoutePhaseOrder(entries);
|
|
|
|
for (const entry of entries) {
|
|
assertServiceContract(entry);
|
|
}
|
|
|
|
const cloudSurface = entries.find((entry) => entry.phase === "cloud-surface");
|
|
assert.ok(cloudSurface, "cloud-surface entry must exist");
|
|
assertCloudSurface(cloudSurface);
|
|
|
|
const agentSkills = entries.find((entry) => entry.serviceId === "hwlab-agent-skills");
|
|
assert.equal(agentSkills.digest, "not_applicable");
|
|
assert.equal(agentSkills.digestReason, "local dry-run fixture for repo-local skills bundle");
|
|
|
|
const observations = fixture.observations;
|
|
const expectedObservationIds = [];
|
|
for (const entry of entries) {
|
|
expectedObservationIds.push(entry.serviceId);
|
|
if (entry.serviceId === "hwlab-cloud-api") {
|
|
expectedObservationIds.push("hwlab-cloud-web");
|
|
}
|
|
}
|
|
assert.deepEqual(observations.serviceIds, expectedObservationIds, "observation service ids must track validated entries");
|
|
assert.deepEqual(
|
|
observations.routePhases,
|
|
["master-edge-proxy", "frp-tunnel", "d601-router", "cloud-surface", "agent-runtime", "sim-and-patch-panel"],
|
|
"route phase labels must stay frozen"
|
|
);
|
|
assertTimestamp(observations.healthTimestamp, "observations.healthTimestamp");
|
|
}
|
|
|
|
function redactedHeaders(headers) {
|
|
return Object.fromEntries(
|
|
["content-type", "content-length", "cache-control", "date"].flatMap((name) => {
|
|
const value = headers.get(name);
|
|
return value ? [[name, value]] : [];
|
|
})
|
|
);
|
|
}
|
|
|
|
async function probeHttp({ id, url, expect }) {
|
|
const startedAt = Date.now();
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), expect.timeoutMs);
|
|
try {
|
|
const response = await fetch(url, { signal: controller.signal });
|
|
const body = await response.text();
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
let json = null;
|
|
if (contentType.includes("application/json")) {
|
|
try {
|
|
json = JSON.parse(body);
|
|
} catch {
|
|
json = null;
|
|
}
|
|
}
|
|
return {
|
|
id,
|
|
url,
|
|
ok: response.ok,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
durationMs: Date.now() - startedAt,
|
|
contentType,
|
|
headers: redactedHeaders(response.headers),
|
|
json,
|
|
title: body.match(/<title>([^<]+)<\/title>/iu)?.[1] ?? null,
|
|
bodyPreview: body.slice(0, 512)
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
id,
|
|
url,
|
|
ok: false,
|
|
status: null,
|
|
statusText: null,
|
|
durationMs: Date.now() - startedAt,
|
|
contentType: null,
|
|
headers: {},
|
|
json: null,
|
|
title: null,
|
|
bodyPreview: "",
|
|
error: error?.name === "AbortError" ? `timeout after ${expect.timeoutMs}ms` : String(error?.message ?? error)
|
|
};
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function acceptedProbe(probe) {
|
|
if (!probe.ok) return false;
|
|
if (probe.id === "frontend-root") {
|
|
return probe.contentType?.includes("text/html") && probe.title === "HWLAB DEV MVP Gate";
|
|
}
|
|
return Boolean(
|
|
probe.json &&
|
|
SERVICE_IDS.includes(probe.json.serviceId) &&
|
|
probe.json.environment === ENVIRONMENT_DEV &&
|
|
(probe.json.endpoint === undefined || probe.json.endpoint === DEV_ENDPOINT)
|
|
);
|
|
}
|
|
|
|
function probeEvidenceLine(probe) {
|
|
const accepted = acceptedProbe(probe) ? "yes" : "no";
|
|
const identity = probe.json?.serviceId ?? probe.title ?? "unknown";
|
|
return `${probe.id}: HTTP ${probe.status ?? "none"} accepted=${accepted} identity=${identity} url=${probe.url}`;
|
|
}
|
|
|
|
async function gitCommitId() {
|
|
try {
|
|
const result = await execFileAsync("git", ["rev-parse", "--short=12", "HEAD"], {
|
|
cwd: repoRoot,
|
|
timeout: 5000
|
|
});
|
|
return result.stdout.trim();
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
}
|
|
|
|
function buildActiveReport({ fixture, probes, generatedAt, commitId }) {
|
|
const passed = probes.every(acceptedProbe);
|
|
const status = passed ? "pass" : "blocked";
|
|
const commands = [
|
|
"curl -fsS --max-time 10 http://74.48.78.17:16667/health",
|
|
"curl -fsS --max-time 10 http://74.48.78.17:16667/health/live",
|
|
"curl -fsS --max-time 10 http://74.48.78.17:16666/"
|
|
];
|
|
|
|
return {
|
|
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
|
|
$id: "https://hwlab.pikastech.local/dev-gate/dev-m2-deploy-smoke-active.json",
|
|
reportVersion: "v1",
|
|
issue: "pikasTech/HWLAB#23",
|
|
taskId: "m2-dev-deploy-smoke",
|
|
commitId,
|
|
acceptanceLevel: "dev_m2_deploy_smoke",
|
|
devOnly: true,
|
|
prodDisabled: true,
|
|
reportLifecycle: activeReportLifecycle(
|
|
"Active M2 read-only public endpoint smoke; route reachability is not M5 DEV-LIVE acceptance."
|
|
),
|
|
status,
|
|
generatedAt,
|
|
endpoint: DEV_ENDPOINT,
|
|
frontendEndpoint: DEV_FRONTEND_ENDPOINT,
|
|
sourceContract: {
|
|
status: "pass",
|
|
documents: [
|
|
"docs/m2-dev-deploy-smoke.md",
|
|
"docs/dev-acceptance-matrix.md",
|
|
"fixtures/dev-deploy-smoke/dev-deploy-smoke.json"
|
|
],
|
|
summary: "M2 DEV deploy smoke is rebaselined to frontend :16666 and API/edge :16667."
|
|
},
|
|
validationCommands: [
|
|
"node --check scripts/m2-dev-deploy-smoke.mjs",
|
|
"node scripts/m2-dev-deploy-smoke.mjs --dry-run",
|
|
`node scripts/m2-dev-deploy-smoke.mjs --live --confirm-dev --confirmed-non-production --report ${defaultActiveReportPath}`,
|
|
"node --check scripts/validate-dev-gate-report.mjs",
|
|
"node scripts/validate-dev-gate-report.mjs"
|
|
],
|
|
localSmoke: {
|
|
status: "pass",
|
|
commands: ["node scripts/m2-dev-deploy-smoke.mjs --dry-run"],
|
|
evidence: [
|
|
`[m2-smoke] validated ${fixture.serviceContracts.length} service contracts`,
|
|
`[m2-smoke] mode=dry-run endpoint=${DEV_ENDPOINT}`,
|
|
"[m2-smoke] no real DEV/PROD request was made"
|
|
],
|
|
summary: "The fixture smoke validates the M2 route and artifact contract without contacting DEV."
|
|
},
|
|
dryRun: {
|
|
status: "pass",
|
|
commands: ["node scripts/m2-dev-deploy-smoke.mjs --dry-run"],
|
|
evidence: [
|
|
`fixture endpoint=${DEV_ENDPOINT}`,
|
|
`fixture frontendEndpoint=${DEV_FRONTEND_ENDPOINT}`,
|
|
"legacy :6667 public evidence is marked ineligible for active green"
|
|
],
|
|
summary: "Dry-run evidence is contract-only and pinned to the current DEV public endpoints."
|
|
},
|
|
devPreconditions: {
|
|
status,
|
|
requirements: [
|
|
"GET http://74.48.78.17:16667/health returns HWLAB DEV JSON",
|
|
"GET http://74.48.78.17:16667/health/live returns HWLAB DEV JSON",
|
|
"GET http://74.48.78.17:16666/ returns the HWLAB DEV frontend",
|
|
"No PROD, secret read, runtime restart, deployment, or heavyweight e2e action is performed"
|
|
],
|
|
commands,
|
|
evidence: probes.map(probeEvidenceLine),
|
|
summary: passed
|
|
? "Read-only DEV public endpoint probes passed on :16666/:16667."
|
|
: "One or more read-only DEV public endpoint probes failed; no active green is claimed."
|
|
},
|
|
blockers: passed
|
|
? []
|
|
: [
|
|
{
|
|
type: "network_blocker",
|
|
scope: "m2-public-endpoints",
|
|
status: "open",
|
|
summary: "One or more M2 DEV public endpoint probes failed on :16666/:16667."
|
|
}
|
|
],
|
|
runtimeSmoke: {
|
|
mode: "live-read-only",
|
|
status,
|
|
generatedAt,
|
|
apiEndpoint: DEV_ENDPOINT,
|
|
frontendEndpoint: DEV_FRONTEND_ENDPOINT,
|
|
safety: {
|
|
environment: ENVIRONMENT_DEV,
|
|
prodTouched: false,
|
|
secretsRead: false,
|
|
restarts: false,
|
|
deployAttempted: false,
|
|
heavyE2E: false,
|
|
unideskRuntimeSubstitute: false
|
|
},
|
|
probes,
|
|
legacyPublicEndpoint: {
|
|
endpoint: legacyPublicEndpoint,
|
|
status: "deprecated",
|
|
activeGreenEligible: false,
|
|
summary: "Legacy public :6667 observations are retained only as historical blockers and are not accepted as active green evidence."
|
|
}
|
|
},
|
|
notes: "M2 active report uses read-only GET probes only; PROD, service restarts, deployments, and script-driven apply flows were not touched."
|
|
};
|
|
}
|
|
|
|
async function writeActiveReport(report, reportPath) {
|
|
const absoluteReportPath = ensureNotRepoReportsPath(repoRoot, reportPath, "--report");
|
|
await mkdir(path.dirname(absoluteReportPath), { recursive: true });
|
|
await writeFile(absoluteReportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
}
|
|
|
|
async function runLiveSmoke(fixture, options) {
|
|
const probes = await Promise.all([
|
|
probeHttp({
|
|
id: "api-health",
|
|
url: new URL("/health", DEV_ENDPOINT).href,
|
|
expect: { timeoutMs: options.timeoutMs }
|
|
}),
|
|
probeHttp({
|
|
id: "api-live",
|
|
url: new URL("/health/live", DEV_ENDPOINT).href,
|
|
expect: { timeoutMs: options.timeoutMs }
|
|
}),
|
|
probeHttp({
|
|
id: "frontend-root",
|
|
url: new URL("/", DEV_FRONTEND_ENDPOINT).href,
|
|
expect: { timeoutMs: options.timeoutMs }
|
|
})
|
|
]);
|
|
|
|
const report = buildActiveReport({
|
|
fixture,
|
|
probes,
|
|
generatedAt: new Date().toISOString(),
|
|
commitId: await gitCommitId()
|
|
});
|
|
return report;
|
|
}
|
|
|
|
async function main() {
|
|
const { flags, options } = parseArgs(process.argv.slice(2));
|
|
const isLive = flags.has("--live") || flags.has("--allow-live");
|
|
|
|
if (isLive) {
|
|
assert.ok(!flags.has("--dry-run"), "do not combine --live with --dry-run");
|
|
assert.ok(flags.has("--confirm-dev"), "live mode requires --confirm-dev");
|
|
assert.ok(flags.has("--confirmed-non-production"), "live mode requires --confirmed-non-production");
|
|
assert.ok(!flags.has("--prod"), "PROD requests are forbidden");
|
|
assert.ok(!flags.has("--heavyweight-e2e"), "heavyweight e2e is forbidden");
|
|
assert.ok(!flags.has("--read-secret"), "secret reads are forbidden");
|
|
assert.ok(!flags.has("--force-push"), "force push is forbidden");
|
|
}
|
|
|
|
const fixture = await loadFixture();
|
|
assertFixture(fixture);
|
|
assert.equal(fixture.endpoint, DEV_ENDPOINT, "fixture must stay pinned to the frozen DEV endpoint");
|
|
|
|
process.stdout.write(`[m2-smoke] validated ${fixture.serviceContracts.length} service contracts\n`);
|
|
process.stdout.write(`[m2-smoke] mode=${isLive ? "live" : "dry-run"} endpoint=${fixture.endpoint}\n`);
|
|
if (isLive) {
|
|
const report = await runLiveSmoke(fixture, options);
|
|
if (options.writeReport) {
|
|
await writeActiveReport(report, options.reportPath);
|
|
process.stdout.write(`[m2-smoke] wrote ${path.relative(repoRoot, options.reportPath)}\n`);
|
|
}
|
|
process.stdout.write(`[m2-smoke] active-report=${report.status} probes=${report.runtimeSmoke.probes.length}\n`);
|
|
process.stdout.write("[m2-smoke] no DEV/PROD mutation was made\n");
|
|
if (report.status !== "pass") {
|
|
process.exitCode = 2;
|
|
}
|
|
} else {
|
|
process.stdout.write("[m2-smoke] no real DEV/PROD request was made\n");
|
|
}
|
|
}
|
|
|
|
try {
|
|
await main();
|
|
} catch (error) {
|
|
process.stderr.write(`[m2-smoke] ${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exitCode = 1;
|
|
}
|