You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/09/14 13:43:39 UTC
(juneau) branch juneau-release-manager updated: WORK-R0009: wire Setup templates, rm-setup.js, AppConfiguration/RootRest, e2e, and Setup/Home tests.
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch juneau-release-manager
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/juneau-release-manager by this push:
new 7396284389 WORK-R0009: wire Setup templates, rm-setup.js, AppConfiguration/RootRest, e2e, and Setup/Home tests.
7396284389 is described below
commit 739628438968b96ae086a2c45bcb13210e38cfe5
Author: James Bognar <ja...@apache.org>
AuthorDate: Mon Sep 14 09:43:33 2026 -0400
WORK-R0009: wire Setup templates, rm-setup.js, AppConfiguration/RootRest, e2e, and Setup/Home tests.
---
e2e/playwright.config.ts | 2 +-
e2e/tests/home.spec.ts | 18 +-
e2e/tests/nav.spec.ts | 16 +-
e2e/tests/smoke.spec.ts | 26 +--
.../org/apache/juneau/releng/AppConfiguration.java | 14 ++
.../java/org/apache/juneau/releng/RootRest.java | 5 +-
src/main/resources/static/css/chrome.css | 101 ++++++++---
src/main/resources/static/index.html | 4 +-
src/main/resources/static/js/rm-setup.js | 139 +++++++++++++++
src/main/resources/templates/admin.ftlh | 1 -
src/main/resources/templates/base.ftlh | 6 +-
src/main/resources/templates/credentials.ftlh | 57 ------
src/main/resources/templates/home.ftlh | 42 -----
.../resources/templates/{admin.ftlh => setup.ftlh} | 31 ++--
...treamTest.java => ReleaseManagerThemeTest.java} | 36 ++--
.../juneau/releng/rest/CredentialRestTest.java | 23 ++-
.../HomeRestTest.java} | 36 ++--
.../apache/juneau/releng/rest/SetupRestTest.java | 131 ++++++++++++++
.../juneau/releng/setup/SetupProbeServiceTest.java | 193 +++++++++++++++++++++
.../releng/util/ProcessRunnerStreamTest.java | 22 +++
start.sh | 3 +-
21 files changed, 683 insertions(+), 223 deletions(-)
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
index ca9f9212a6..56c304b934 100644
--- a/e2e/playwright.config.ts
+++ b/e2e/playwright.config.ts
@@ -68,7 +68,7 @@ export default defineConfig({
webServer: {
command:
`mvn -q spring-boot:run -Dspring-boot.run.arguments=--server.port=${TEST_PORT}`,
- url: BASE_URL + '/rest/home',
+ url: BASE_URL + '/rest/setup',
cwd: APP_ROOT,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts
index c247a7ae3d..11c399eecd 100644
--- a/e2e/tests/home.spec.ts
+++ b/e2e/tests/home.spec.ts
@@ -17,15 +17,19 @@
import { test, expect } from '@playwright/test';
-test.describe('Home page', () => {
- test('boots and renders the workflow heading', async ({ page }) => {
- const response = await page.goto('/rest/home');
+test.describe('Setup page', () => {
+ test('boots probes and details; home 302s here', async ({ page }) => {
+ const home = await page.goto('/rest/home');
+ expect(home?.status()).toBe(200);
+ await expect(page).toHaveURL(/\/rest\/setup/);
+
+ const response = await page.goto('/rest/setup');
expect(response?.status()).toBe(200);
- await expect(page.getByRole('heading', { name: 'Workflow' })).toBeVisible();
- await expect(page.getByRole('heading', { name: 'Prerequisites' })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Probes' })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Details' })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Workflow' })).toHaveCount(0);
- // Home nav tab is marked active on its own page.
- await expect(page.getByRole('link', { name: 'Home' })).toHaveClass(/active/);
+ await expect(page.getByRole('link', { name: 'Setup' })).toHaveClass(/active/);
});
});
diff --git a/e2e/tests/nav.spec.ts b/e2e/tests/nav.spec.ts
index 85fb52a447..9cfd8da45e 100644
--- a/e2e/tests/nav.spec.ts
+++ b/e2e/tests/nav.spec.ts
@@ -22,9 +22,8 @@ import { test, expect, type Page } from '@playwright/test';
* which route each tab should land on and what landmark proves the right page rendered. Kept independent of the
* control-row/ribbon markup (which is in flux) — these are page-level landmarks only.
*/
-const NAV_TABS: { name: string; path: string; heading: string | RegExp }[] = [
- { name: 'Home', path: '/rest/home', heading: 'Workflow' },
- { name: 'Credentials', path: '/rest/credentials', heading: /apache|github|gpg/i },
+const NAV_TABS: { name: string; path: string; heading: string | RegExp | null }[] = [
+ { name: 'Setup', path: '/rest/setup', heading: 'Probes' },
{ name: 'Releases', path: '/rest/releases', heading: 'All Releases' },
// New Release has no single fixed heading — its Input-vs-Execution subtab starts on whichever one matches
// current run state (state-dependent), so it's asserted separately below via the always-present subtab bar.
@@ -32,8 +31,6 @@ const NAV_TABS: { name: string; path: string; heading: string | RegExp }[] = [
{ name: 'Admin', path: '/rest/admin', heading: 'Admin' },
];
-// The Admin page composes its own tab bar reusing the "Releases"/"Credentials" labels, so every top-nav lookup
-// is scoped to the persistent header nav (base.ftlh's `nav.jc-nav`) to avoid ambiguity there.
function topNav(page: Page) {
return page.locator('nav.jc-nav');
}
@@ -41,7 +38,7 @@ function topNav(page: Page) {
test.describe('Top navigation', () => {
for (const tab of NAV_TABS) {
test(`"${tab.name}" tab is present and navigates to a working page`, async ({ page }) => {
- await page.goto('/rest/home');
+ await page.goto('/rest/setup');
const link = topNav(page).getByRole('link', { name: tab.name, exact: true });
await expect(link).toBeVisible();
@@ -51,12 +48,9 @@ test.describe('Top navigation', () => {
expect(response?.status()).toBe(200);
if (tab.name === 'New Release') {
- // Input/Execution subtabs (role="tab") are always present regardless of whether a run is active.
await expect(page.getByRole('tab', { name: 'Input' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Execution' })).toBeVisible();
} else if (tab.heading instanceof RegExp) {
- // Credentials has no single fixed heading (data-driven content), so assert on a resilient text
- // landmark instead of a specific DOM structure.
await expect(page.getByText(tab.heading).first()).toBeVisible();
} else if (tab.heading) {
await expect(page.getByRole('heading', { name: tab.heading })).toBeVisible();
@@ -64,12 +58,14 @@ test.describe('Top navigation', () => {
});
}
- test('all five nav tabs are present on every page', async ({ page }) => {
+ test('all four nav tabs are present on every page', async ({ page }) => {
for (const startTab of NAV_TABS) {
await page.goto(startTab.path);
for (const tab of NAV_TABS) {
await expect(topNav(page).getByRole('link', { name: tab.name, exact: true })).toBeVisible();
}
+ await expect(topNav(page).getByRole('link', { name: 'Home', exact: true })).toHaveCount(0);
+ await expect(topNav(page).getByRole('link', { name: 'Credentials', exact: true })).toHaveCount(0);
}
});
});
diff --git a/e2e/tests/smoke.spec.ts b/e2e/tests/smoke.spec.ts
index 35ff558730..df23dfa9c2 100644
--- a/e2e/tests/smoke.spec.ts
+++ b/e2e/tests/smoke.spec.ts
@@ -46,24 +46,24 @@ test.describe('Smoke: primary content renders without console errors', () => {
expect(response?.status()).toBe(200);
await expect(page.getByRole('heading', { name: 'Admin' })).toBeVisible();
- // Composed tab bar (PageTable) — leaf tabs for the two dogfooded views. Scoped to .jc-tab-bar (the
- // PageTable-owned tablist) since the top nav also has same-named "Releases"/"Credentials" links.
- const adminTabBar = page.locator('.jc-tab-bar');
- await expect(adminTabBar.getByText('Releases', { exact: true })).toBeVisible();
- await expect(adminTabBar.getByText('Credentials', { exact: true })).toBeVisible();
+ const adminNav = page.locator('.juneau-page-nav');
+ await expect(adminNav.getByText('Releases', { exact: true })).toBeVisible();
+ await expect(adminNav.getByText('Credentials', { exact: true })).toHaveCount(0);
expect(errors, `console errors on /rest/admin: ${errors.join('; ')}`).toEqual([]);
});
- test('Credentials page renders its primary content', async ({ page }) => {
- const errors = collectConsoleErrors(page);
+ test('Credentials human page is 404', async ({ page }) => {
const response = await page.goto('/rest/credentials');
- expect(response?.status()).toBe(200);
-
- // One card per managed credential (Apache LDAP / GPG / GitHub) — assert on the label text rather than a
- // specific count, since the set of managed credentials could grow.
- await expect(page.getByText(/GitHub token/i)).toBeVisible();
+ expect(response?.status()).toBe(404);
+ });
- expect(errors, `console errors on /rest/credentials: ${errors.join('; ')}`).toEqual([]);
+ test('Setup page renders probes', async ({ page }) => {
+ const errors = collectConsoleErrors(page);
+ const response = await page.goto('/rest/setup');
+ expect(response?.status()).toBe(200);
+ await expect(page.getByRole('heading', { name: 'Probes' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'GitHub token' })).toBeVisible();
+ expect(errors, `console errors on /rest/setup: ${errors.join('; ')}`).toEqual([]);
});
});
diff --git a/src/main/java/org/apache/juneau/releng/AppConfiguration.java b/src/main/java/org/apache/juneau/releng/AppConfiguration.java
index e3639b614a..d0b1766a86 100644
--- a/src/main/java/org/apache/juneau/releng/AppConfiguration.java
+++ b/src/main/java/org/apache/juneau/releng/AppConfiguration.java
@@ -54,6 +54,8 @@ import org.apache.juneau.releng.release.ReleaseListService;
import org.apache.juneau.releng.rest.CredentialRest;
import org.apache.juneau.releng.rest.HomeRest;
import org.apache.juneau.releng.rest.MilestoneRest;
+import org.apache.juneau.releng.rest.SetupRest;
+import org.apache.juneau.releng.setup.SetupProbeService;
import org.apache.juneau.releng.rest.ReleaseRest;
import org.apache.juneau.releng.rest.ReleaseRunRest;
import org.apache.juneau.releng.util.ProcessRunner;
@@ -194,6 +196,18 @@ public class AppConfiguration {
return new HomeRest();
}
+ @Bean
+ public SetupProbeService setupProbeService(ProcessRunner runner, CredentialService credentials,
+ @Value("${rm.repo.dir}") String repoDir) {
+ return new SetupProbeService(runner, credentials, Path.of(repoDir),
+ Path.of(System.getProperty("user.home"), ".m2", "settings.xml"));
+ }
+
+ @Bean
+ public SetupRest setupRest(SetupProbeService setup) {
+ return new SetupRest(setup);
+ }
+
@Bean
public ReleaseRest releaseRest(ReleaseListService svc) {
return new ReleaseRest(svc);
diff --git a/src/main/java/org/apache/juneau/releng/RootRest.java b/src/main/java/org/apache/juneau/releng/RootRest.java
index 625bd25d28..d6dbcc1eda 100644
--- a/src/main/java/org/apache/juneau/releng/RootRest.java
+++ b/src/main/java/org/apache/juneau/releng/RootRest.java
@@ -25,9 +25,10 @@ import org.apache.juneau.releng.rest.MilestoneRest;
import org.apache.juneau.releng.rest.CredentialRest;
import org.apache.juneau.releng.rest.ReleaseRunRest;
import org.apache.juneau.releng.rest.AdminRest;
+import org.apache.juneau.releng.rest.SetupRest;
-@Rest(path = "/rest/*", title = "Apache Juneau · Release Manager", children = { HomeRest.class, ReleaseRest.class,
- MilestoneRest.class, CredentialRest.class, ReleaseRunRest.class, AdminRest.class })
+@Rest(path = "/rest/*", title = "Apache Juneau · Release Manager", children = { SetupRest.class, HomeRest.class,
+ ReleaseRest.class, MilestoneRest.class, CredentialRest.class, ReleaseRunRest.class, AdminRest.class })
@SuppressWarnings({ "java:S110" // Inheritance depth is imposed by the Juneau REST servlet hierarchy.
})
public class RootRest extends BasicSpringRestServletGroup {
diff --git a/src/main/resources/static/css/chrome.css b/src/main/resources/static/css/chrome.css
index 8b28d2ff97..4baac3dff3 100644
--- a/src/main/resources/static/css/chrome.css
+++ b/src/main/resources/static/css/chrome.css
@@ -25,9 +25,9 @@
/juneau-console/chrome.css (see base.ftlh's <head> link). This file holds
only what's genuinely app-specific and has no console-ui equivalent:
DataTables-chrome integration, credential cards, the release-detail list
- grid, two app-only tag values, and Home-tab content styling. Every
+ grid, two app-only tag values, and Setup-tab probe styling. Every
var(--jc-*) reference below resolves against the tokens the shipped chrome
- response appends (Theme.OPEN, overridden by ReleaseManagerTheme).
+ response appends (Theme.LIGHT_RED, overridden by ReleaseManagerTheme).
========================================================================== */
/* ==========================================================================
@@ -287,41 +287,102 @@ table.dataTable.rm-table thead > tr > th.dt-ordering-desc .dt-column-order:after
}
/* ==========================================================================
- Home tab (welcome / instructions)
+ Setup tab (probes + details)
========================================================================== */
-.rm-home-steps,
-.rm-home-prereqs {
+.rm-setup {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 16px;
+ align-items: start;
+ width: 100%;
+ max-width: 1180px;
+ margin: 0 auto;
+}
+
+.rm-setup > .jc-card {
+ max-width: none;
margin: 0;
- padding: 4px 0 4px 20px;
+ min-width: 0;
+}
+
+.rm-probe-row {
+ display: flex;
+ flex-wrap: nowrap;
+ gap: 8px;
+ padding: 4px 0 8px;
+ overflow-x: auto;
+}
+
+.rm-probe-pill {
+ font-family: var(--jc-font);
font-size: 0.8125rem;
- color: var(--jc-text);
- line-height: 1.6;
+ font-weight: 600;
+ border: 1px solid var(--jc-border);
+ border-radius: 999px;
+ padding: 4px 12px;
+ background: var(--jc-tag-neutral-bg);
+ color: var(--jc-tag-neutral-text);
+ cursor: pointer;
}
-.rm-home-steps li,
-.rm-home-prereqs li {
- margin-bottom: 8px;
+.rm-probe-pill-pass {
+ background: var(--jc-tag-green-bg);
+ color: var(--jc-tag-green-text);
+ border-color: var(--jc-tag-green-border);
}
-.rm-home-steps li:last-child,
-.rm-home-prereqs li:last-child {
- margin-bottom: 4px;
+.rm-probe-pill-fail {
+ background: var(--jc-tag-red-bg);
+ color: var(--jc-tag-red-text);
+ border-color: var(--jc-tag-red-border);
}
-.rm-home-steps code,
-.rm-home-prereqs code,
-.rm-page-sub code {
+.rm-probe-pill-warn {
+ background: var(--jc-tag-amber-bg);
+ color: var(--jc-tag-amber-text);
+ border-color: var(--jc-tag-amber-border);
+}
+
+.rm-probe-pill-pending {
+ background: var(--jc-tag-neutral-bg);
+ color: var(--jc-tag-neutral-text);
+}
+
+.rm-probe-pill-selected {
+ box-shadow: 0 0 0 2px var(--jc-accent);
+ background: var(--jc-accent-wash);
+}
+
+.rm-probe-placeholder {
+ color: var(--jc-text-muted);
+ font-size: 0.875rem;
+ margin: 8px 0;
+}
+
+.rm-probe-copy,
+.rm-probe-install-out {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.75rem;
background: var(--jc-chrome-bg);
border: 1px solid var(--jc-border);
- border-radius: 3px;
- padding: 1px 5px;
+ border-radius: var(--jc-radius);
+ padding: 8px 10px;
+ overflow-x: auto;
+ white-space: pre-wrap;
+}
+
+#rm-probe-copy-btn,
+#rm-probe-install-btn {
+ margin: 8px 8px 0 0;
+}
+
+@media (max-width: 720px) {
+ .rm-setup { grid-template-columns: 1fr; }
}
/* ==========================================================================
- Credential cards (Credentials tab)
+ Credential cards (Setup Details write UI)
==========================================================================
Not shown in the design mockup (which only covers the Releases tab), so
diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html
index afd0fc96f1..00c478cbe4 100644
--- a/src/main/resources/static/index.html
+++ b/src/main/resources/static/index.html
@@ -20,9 +20,9 @@
<head>
<meta charset="utf-8">
<title>Apache Juneau Release Manager</title>
- <script>location.replace('/rest/home');</script>
+ <script>location.replace('/rest/setup');</script>
</head>
<body>
- <noscript>Redirecting to <a href="/rest/home">the Release Manager</a>…</noscript>
+ <noscript>Redirecting to <a href="/rest/setup">the Release Manager</a>…</noscript>
</body>
</html>
diff --git a/src/main/resources/static/js/rm-setup.js b/src/main/resources/static/js/rm-setup.js
new file mode 100644
index 0000000000..cd9ba551c2
--- /dev/null
+++ b/src/main/resources/static/js/rm-setup.js
@@ -0,0 +1,139 @@
+/*
+ * 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.
+ */
+
+(function () {
+ const row = document.getElementById('rm-probe-row');
+ const details = document.getElementById('rm-probe-details');
+ if (!row || !details) return;
+
+ let probes = [];
+ let selectedId = null;
+
+ function statusClass(status) {
+ if (status === 'pass') return 'rm-probe-pill-pass';
+ if (status === 'fail') return 'rm-probe-pill-fail';
+ if (status === 'warn') return 'rm-probe-pill-warn';
+ return 'rm-probe-pill-pending';
+ }
+
+ function escapeHtml(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+ }[c]));
+ }
+
+ function renderPills() {
+ row.querySelectorAll('[data-probe-id]').forEach((el) => {
+ const p = probes.find((x) => x.id === el.getAttribute('data-probe-id'));
+ el.className = 'rm-probe-pill ' + statusClass(p ? p.status : 'pending');
+ if (el.getAttribute('data-probe-id') === selectedId)
+ el.classList.add('rm-probe-pill-selected');
+ });
+ }
+
+ function credentialForm(p) {
+ const name = p.credentialName;
+ let acct = '';
+ if (name !== 'github') {
+ const ph = name === 'apache' ? 'Apache availid' : 'GPG key ID';
+ acct = '<input type="text" class="acct" placeholder="' + ph + '">';
+ }
+ const sph = name === 'apache' ? 'password' : name === 'gpg' ? 'passphrase' : 'token';
+ return '<div class="cred" data-name="' + escapeHtml(name) + '">'
+ + '<div class="row">'
+ + acct
+ + '<input type="password" class="secret" placeholder="' + sph + '">'
+ + '<button type="button" class="jc-btn jc-btn-primary" onclick="rmSet(\'' + name + '\', this)">Save</button>'
+ + '<button type="button" class="jc-btn jc-btn-outline" onclick="rmValidate(\'' + name + '\', this)">Validate</button>'
+ + '</div>'
+ + '<div class="msg"></div></div>';
+ }
+
+ function renderDetails() {
+ const p = probes.find((x) => x.id === selectedId);
+ if (!p) {
+ details.innerHTML = '<p class="rm-probe-placeholder">Select a probe.</p>';
+ return;
+ }
+ const bits = [];
+ bits.push('<h3>' + escapeHtml(p.label) + '</h3>');
+ bits.push('<dl class="rm-detail-list">');
+ bits.push('<dt>Status</dt><dd>' + escapeHtml(p.status || 'pending') + '</dd>');
+ bits.push('<dt>Message</dt><dd>' + escapeHtml(p.message || '') + '</dd>');
+ bits.push('<dt>Next step</dt><dd>' + escapeHtml(p.nextStep || '') + '</dd>');
+ bits.push('</dl>');
+ if (p.copyCommand) {
+ bits.push('<pre class="rm-probe-copy" id="rm-probe-copy-text">' + escapeHtml(p.copyCommand) + '</pre>');
+ bits.push('<button type="button" class="jc-btn jc-btn-outline jc-btn-sm" id="rm-probe-copy-btn">Copy command</button>');
+ }
+ if (p.installable) {
+ bits.push('<button type="button" class="jc-btn jc-btn-primary" id="rm-probe-install-btn">Install</button>');
+ bits.push('<pre class="rm-probe-install-out" id="rm-probe-install-out" hidden></pre>');
+ }
+ if (p.kind === 'credential')
+ bits.push(credentialForm(p));
+ details.innerHTML = bits.join('');
+ const copyBtn = document.getElementById('rm-probe-copy-btn');
+ if (copyBtn)
+ copyBtn.addEventListener('click', () => navigator.clipboard.writeText(p.copyCommand));
+ const installBtn = document.getElementById('rm-probe-install-btn');
+ if (installBtn)
+ installBtn.addEventListener('click', () => install(p.id));
+ }
+
+ async function install(id) {
+ const out = document.getElementById('rm-probe-install-out');
+ const btn = document.getElementById('rm-probe-install-btn');
+ if (btn) btn.disabled = true;
+ if (out) {
+ out.hidden = false;
+ out.textContent = 'Installing…';
+ }
+ try {
+ const r = await fetch('/rest/setup/install/' + encodeURIComponent(id), { method: 'POST' });
+ const body = await r.json();
+ if (out)
+ out.textContent = body.output || (r.ok ? 'Done' : 'Install failed');
+ await loadData();
+ renderDetails();
+ } catch (e) {
+ if (out)
+ out.textContent = String(e);
+ } finally {
+ if (btn) btn.disabled = false;
+ }
+ }
+
+ async function loadData() {
+ const r = await fetch('/rest/setup/data');
+ const body = await r.json();
+ probes = body.probes || [];
+ renderPills();
+ if (selectedId)
+ renderDetails();
+ }
+
+ row.addEventListener('click', (ev) => {
+ const btn = ev.target.closest('[data-probe-id]');
+ if (!btn) return;
+ selectedId = btn.getAttribute('data-probe-id');
+ renderPills();
+ renderDetails();
+ });
+
+ loadData();
+})();
diff --git a/src/main/resources/templates/admin.ftlh b/src/main/resources/templates/admin.ftlh
index 9e275821c9..10df6bc012 100644
--- a/src/main/resources/templates/admin.ftlh
+++ b/src/main/resources/templates/admin.ftlh
@@ -28,7 +28,6 @@
</div>
<div class="juneau-page-nav-children">
<a class="juneau-page-nav-child" href="/rest/admin/releases"<#if selectedChild == 'releases'> aria-current="page"</#if>>Releases</a>
- <a class="juneau-page-nav-child" href="/rest/admin/credentials"<#if selectedChild == 'credentials'> aria-current="page"</#if>>Credentials</a>
</div>
</nav>
<div id="${slotId}"></div>
diff --git a/src/main/resources/templates/base.ftlh b/src/main/resources/templates/base.ftlh
index ec50d97ec1..816a64a480 100644
--- a/src/main/resources/templates/base.ftlh
+++ b/src/main/resources/templates/base.ftlh
@@ -74,8 +74,7 @@
assigns before including this layout — no hardcoded per-page string
compares here. -->
<nav class="jc-nav">
- <a class="jc-nav-tab<#if activeTab?? && activeTab == 'home'> active</#if>" href="/rest/home">Home</a>
- <a class="jc-nav-tab<#if activeTab?? && activeTab == 'credentials'> active</#if>" href="/rest/credentials">Credentials</a>
+ <a class="jc-nav-tab<#if activeTab?? && activeTab == 'setup'> active</#if>" href="/rest/setup">Setup</a>
<a class="jc-nav-tab<#if activeTab?? && activeTab == 'releases'> active</#if>" href="/rest/releases">Releases</a>
<a class="jc-nav-tab<#if activeTab?? && activeTab == 'new'> active</#if>" href="/rest/runs">New Release</a>
<a class="jc-nav-tab<#if activeTab?? && activeTab == 'admin'> active</#if>" href="/rest/admin">Admin</a>
@@ -114,8 +113,9 @@
<script src="${tableSlotJsUrl}"></script>
</#if>
</#if>
- <#if activeTab?? && activeTab == 'credentials'>
+ <#if activeTab?? && activeTab == 'setup'>
<script src="/js/credentials.js"></script>
+ <script src="/js/rm-setup.js"></script>
</#if>
<#if activeTab?? && activeTab == 'new'>
<link rel="stylesheet" href="/css/new-release.css">
diff --git a/src/main/resources/templates/credentials.ftlh b/src/main/resources/templates/credentials.ftlh
deleted file mode 100644
index 3cd21d906e..0000000000
--- a/src/main/resources/templates/credentials.ftlh
+++ /dev/null
@@ -1,57 +0,0 @@
-<#--
- 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.
--->
-
-<#assign activeTab = "credentials">
-<#macro content>
- <div class="jc-card">
- <#list credentials as c>
- <div class="cred" data-name="${c.name}">
- <#--
- Four states, four treatments. "Stored but never tested" used to share the red `invalid` class, which
- claimed the credential was bad when the app had no idea and sent the user off to re-enter a password
- that was probably fine. It must not read as a pass either -- hence amber rather than green.
-
- lastValid is a boxed Boolean whose null means "never validated", so the untested branch tests for
- absence (!c.lastValid??) and must come before the branches that read the value. Validation results
- are held in memory only, so a restart correctly returns every credential here.
- -->
- <#if !c.present>
- <#assign pillClass = 'unset'><#assign pillLabel = 'Not set'>
- <#elseif !c.lastValid??>
- <#assign pillClass = 'untested'><#assign pillLabel = 'Stored — not tested'>
- <#elseif c.lastValid>
- <#assign pillClass = 'valid'><#assign pillLabel = 'Valid'>
- <#else>
- <#assign pillClass = 'invalid'><#assign pillLabel = 'Invalid'>
- </#if>
- <h3>${c.label}
- <span class="pill ${pillClass}">${pillLabel}</span>
- </h3>
- <div class="row">
- <#if c.name != 'github'>
- <input type="text" class="acct" placeholder="<#if c.name=='apache'>Apache availid<#else>GPG key ID</#if>">
- </#if>
- <input type="password" class="secret" placeholder="<#if c.name=='apache'>password<#elseif c.name=='gpg'>passphrase<#else>token</#if>">
- <button class="jc-btn jc-btn-primary" onclick="rmSet('${c.name}', this)">Save</button>
- <button class="jc-btn jc-btn-outline" onclick="rmValidate('${c.name}', this)">Validate</button>
- </div>
- <div class="msg">${c.lastMessage!''}</div>
- </div>
- </#list>
- </div>
-</#macro>
-<#include "base.ftlh">
diff --git a/src/main/resources/templates/home.ftlh b/src/main/resources/templates/home.ftlh
deleted file mode 100644
index 1ab226d10d..0000000000
--- a/src/main/resources/templates/home.ftlh
+++ /dev/null
@@ -1,42 +0,0 @@
-<#--
- 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.
--->
-
-<#assign activeTab = "home">
-<#macro content>
- <div class="jc-card">
- <div class="jc-card-header">
- <h2>Workflow</h2>
- </div>
- <ol class="rm-home-steps">
- <li><strong><a href="/rest/credentials">Credentials</a></strong> — configure and validate your Apache LDAP password, GPG signing key, and GitHub token first. Everything else depends on these being set up correctly.</li>
- <li><strong><a href="/rest/releases">Releases</a></strong> — review past and existing releases and their current status.</li>
- <li><strong><a href="/rest/releases#new">New Release</a></strong> — start and drive a new release through its steps.</li>
- </ol>
- </div>
-
- <div class="jc-card">
- <div class="jc-card-header">
- <h2>Prerequisites</h2>
- </div>
- <ul class="rm-home-prereqs">
- <li>A local checkout of <code>apache/juneau</code>.</li>
- <li><code>mvn</code>, <code>git</code>, <code>gh</code>, and <code>gpg</code> available on your <code>PATH</code>.</li>
- <li>Valid <code>apache.releases.https</code> credentials in <code>~/.m2/settings.xml</code>.</li>
- </ul>
- </div>
-</#macro>
-<#include "base.ftlh">
diff --git a/src/main/resources/templates/admin.ftlh b/src/main/resources/templates/setup.ftlh
similarity index 51%
copy from src/main/resources/templates/admin.ftlh
copy to src/main/resources/templates/setup.ftlh
index 9e275821c9..566547a11c 100644
--- a/src/main/resources/templates/admin.ftlh
+++ b/src/main/resources/templates/setup.ftlh
@@ -15,24 +15,27 @@
limitations under the License.
-->
-<#assign activeTab = "admin">
+<#assign activeTab = "setup">
<#macro content>
- <div class="jc-card">
- <div class="jc-card-header">
- <h2>Admin</h2>
+ <div class="rm-setup">
+ <div class="jc-card rm-setup-probes">
+ <div class="jc-card-header">
+ <h2>Probes</h2>
+ </div>
+ <div class="rm-probe-row" id="rm-probe-row">
+ <#list inventory as p>
+ <button type="button" class="rm-probe-pill rm-probe-pill-pending" data-probe-id="${p.id}" data-kind="${p.kind}">${p.label}</button>
+ </#list>
+ </div>
</div>
-
- <nav class="juneau-page-nav" aria-label="Admin">
- <div class="juneau-page-nav-sections">
- <a class="juneau-page-nav-section" href="/rest/admin/releases" aria-current="page">Admin</a>
+ <div class="jc-card rm-setup-details">
+ <div class="jc-card-header">
+ <h2>Details</h2>
</div>
- <div class="juneau-page-nav-children">
- <a class="juneau-page-nav-child" href="/rest/admin/releases"<#if selectedChild == 'releases'> aria-current="page"</#if>>Releases</a>
- <a class="juneau-page-nav-child" href="/rest/admin/credentials"<#if selectedChild == 'credentials'> aria-current="page"</#if>>Credentials</a>
+ <div id="rm-probe-details">
+ <p class="rm-probe-placeholder">Select a probe.</p>
</div>
- </nav>
- <div id="${slotId}"></div>
- <script type="application/json" id="rm-table-slot">${tableSlotJson?no_esc}</script>
+ </div>
</div>
</#macro>
<#include "base.ftlh">
diff --git a/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java b/src/test/java/org/apache/juneau/releng/ReleaseManagerThemeTest.java
similarity index 50%
copy from src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
copy to src/test/java/org/apache/juneau/releng/ReleaseManagerThemeTest.java
index 43d6cc01ed..9fbe446f23 100644
--- a/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
+++ b/src/test/java/org/apache/juneau/releng/ReleaseManagerThemeTest.java
@@ -15,32 +15,26 @@
* limitations under the License.
*/
-package org.apache.juneau.releng.util;
+package org.apache.juneau.releng;
import static org.junit.jupiter.api.Assertions.*;
-import java.util.ArrayList;
-import java.util.List;
-import org.junit.jupiter.api.Test;
-class ProcessRunnerStreamTest {
+import org.apache.juneau.rest.server.console.Theme;
+import org.junit.jupiter.api.Test;
- @Test
- void a01_streamsEachLineToSinkAndReturnsExitCode() {
- var runner = new ProcessRunner.Default();
- var lines = new ArrayList<String>();
- var res = runner.run(List.of("sh", "-c", "printf 'a\\nb\\nc\\n'"), null, null, lines::add);
- assertEquals(0, res.exitCode());
- assertEquals(List.of("a", "b", "c"), lines);
- assertTrue(res.output().contains("a"));
- }
+class ReleaseManagerThemeTest {
@Test
- void a02_nonZeroExitStillStreamsAndReports() {
- var runner = new ProcessRunner.Default();
- var lines = new ArrayList<String>();
- var res = runner.run(List.of("sh", "-c", "echo hi; exit 7"), null, null, lines::add);
- assertEquals(7, res.exitCode());
- assertFalse(res.ok());
- assertEquals(List.of("hi"), lines);
+ void a01_derivesFromLightRedAndKeepsDangerTagTriad() {
+ var tokens = ReleaseManagerTheme.INSTANCE.getTokens();
+ assertEquals("release-manager", ReleaseManagerTheme.INSTANCE.getName());
+ assertEquals(Theme.LIGHT_RED.getTokens().get("--jc-btn-primary"), tokens.get("--jc-btn-primary"));
+ assertEquals(Theme.LIGHT_RED.getTokens().get("--jc-chrome-bg"), tokens.get("--jc-chrome-bg"));
+ assertNotEquals("#BF2600", tokens.get("--jc-chrome-bg"));
+ assertNotEquals("#1589EE", tokens.get("--jc-accent"));
+ assertEquals("#fdeceb", tokens.get("--jc-tag-red-bg"));
+ assertEquals("#c23934", tokens.get("--jc-tag-red-text"));
+ assertEquals("#f3c6c2", tokens.get("--jc-tag-red-border"));
+ assertNotEquals(Theme.OPEN.getTokens().get("--jc-tag-red-text"), tokens.get("--jc-tag-red-text"));
}
}
diff --git a/src/test/java/org/apache/juneau/releng/rest/CredentialRestTest.java b/src/test/java/org/apache/juneau/releng/rest/CredentialRestTest.java
index 2e4890c635..0ff70e5d62 100644
--- a/src/test/java/org/apache/juneau/releng/rest/CredentialRestTest.java
+++ b/src/test/java/org/apache/juneau/releng/rest/CredentialRestTest.java
@@ -34,7 +34,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/**
- * Credentials envelope JSON lives on this resource without {@code ViewsMixin}; the human page stays the secret-store UI.
+ * Human Credentials page is 404; write/status APIs stay.
*/
class CredentialRestTest {
@@ -56,25 +56,30 @@ class CredentialRestTest {
}
@Test
- void a01_viewEnvelopeIsSlotMetaWithoutCsrf() throws Exception {
+ void a01_viewEnvelopeIsGone() throws Exception {
try (var client = client()) {
try (var resp = client.request("GET", "/view").header("Accept", "application/json").run()) {
- assertEquals(200, resp.getStatusCode());
- ReleaseRestTest.assertSlotEnvelope(resp.getBodyAsString(), "credentials");
+ assertEquals(404, resp.getStatusCode());
}
}
}
@Test
- void a02_pageRemainsTheSecretStoreUi() throws Exception {
+ void a02_humanPageIs404() throws Exception {
try (var client = client()) {
try (var resp = client.request("GET", "/").run()) {
+ assertEquals(404, resp.getStatusCode());
+ }
+ }
+ }
+
+ @Test
+ void a03_statusRemains() throws Exception {
+ try (var client = client()) {
+ try (var resp = client.request("GET", "/status").header("Accept", "application/json").run()) {
assertEquals(200, resp.getStatusCode());
var body = resp.getBodyAsString();
- assertTrue(body.contains("/js/credentials.js"), body);
- assertFalse(body.contains("class=\"juneau-page-nav\""), body);
- assertFalse(body.contains("id=\"rm-table-slot\""), body);
- assertFalse(body.contains("juneau-view:credentials"), body);
+ assertTrue(body.contains("apache") || body.contains("GitHub") || body.contains("github"), body);
}
}
}
diff --git a/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java b/src/test/java/org/apache/juneau/releng/rest/HomeRestTest.java
similarity index 53%
copy from src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
copy to src/test/java/org/apache/juneau/releng/rest/HomeRestTest.java
index 43d6cc01ed..e8237ce2c3 100644
--- a/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
+++ b/src/test/java/org/apache/juneau/releng/rest/HomeRestTest.java
@@ -15,32 +15,30 @@
* limitations under the License.
*/
-package org.apache.juneau.releng.util;
+package org.apache.juneau.releng.rest;
import static org.junit.jupiter.api.Assertions.*;
-import java.util.ArrayList;
-import java.util.List;
+
+import org.apache.juneau.commons.inject.StackOverlay;
+import org.apache.juneau.rest.mock.MockRestClient;
import org.junit.jupiter.api.Test;
-class ProcessRunnerStreamTest {
+class HomeRestTest {
- @Test
- void a01_streamsEachLineToSinkAndReturnsExitCode() {
- var runner = new ProcessRunner.Default();
- var lines = new ArrayList<String>();
- var res = runner.run(List.of("sh", "-c", "printf 'a\\nb\\nc\\n'"), null, null, lines::add);
- assertEquals(0, res.exitCode());
- assertEquals(List.of("a", "b", "c"), lines);
- assertTrue(res.output().contains("a"));
+ @SuppressWarnings({
+ "resource" // Caller owns and closes the returned MockRestClient (via try-with-resources).
+ })
+ private static MockRestClient client() {
+ return MockRestClient.builder(new HomeRest()).overridingBeanStore(new StackOverlay()).build();
}
@Test
- void a02_nonZeroExitStillStreamsAndReports() {
- var runner = new ProcessRunner.Default();
- var lines = new ArrayList<String>();
- var res = runner.run(List.of("sh", "-c", "echo hi; exit 7"), null, null, lines::add);
- assertEquals(7, res.exitCode());
- assertFalse(res.ok());
- assertEquals(List.of("hi"), lines);
+ void a01_homeRedirectsToSetup() throws Exception {
+ try (var client = client()) {
+ try (var resp = client.request("GET", "/").run()) {
+ assertEquals(302, resp.getStatusCode());
+ assertEquals("/rest/setup", resp.header("Location").getValue());
+ }
+ }
}
}
diff --git a/src/test/java/org/apache/juneau/releng/rest/SetupRestTest.java b/src/test/java/org/apache/juneau/releng/rest/SetupRestTest.java
new file mode 100644
index 0000000000..d5d708ad0e
--- /dev/null
+++ b/src/test/java/org/apache/juneau/releng/rest/SetupRestTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.
+ */
+
+package org.apache.juneau.releng.rest;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.juneau.commons.inject.StackOverlay;
+import org.apache.juneau.commons.secret.InMemorySecretStore;
+import org.apache.juneau.commons.secret.SecretStore;
+import org.apache.juneau.commons.utils.IoUtils;
+import org.apache.juneau.releng.credential.AccountStore;
+import org.apache.juneau.releng.credential.CredentialService;
+import org.apache.juneau.releng.credential.CredentialSpec;
+import org.apache.juneau.releng.setup.SetupProbeService;
+import org.apache.juneau.releng.util.ProcessRunner;
+import org.apache.juneau.rest.mock.MockRestClient;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class SetupRestTest {
+
+ private SetupProbeService service;
+
+ @BeforeEach
+ void setUp(@TempDir Path tmp) {
+ var stores = new EnumMap<CredentialSpec, SecretStore>(CredentialSpec.class);
+ for (var spec : CredentialSpec.values())
+ stores.put(spec, new InMemorySecretStore());
+ var creds = new CredentialService(stores, new EnumMap<>(CredentialSpec.class), new AccountStore(tmp));
+ service = new SetupProbeService(new FakeRunner(), creds, tmp.resolve("missing-repo"), tmp.resolve("missing-settings.xml"));
+ }
+
+ @SuppressWarnings({
+ "resource" // Caller owns and closes the returned MockRestClient (via try-with-resources).
+ })
+ private MockRestClient client() {
+ return MockRestClient.builder(new SetupRest(service)).overridingBeanStore(new StackOverlay()).build();
+ }
+
+ @Test
+ void a01_pageRendersProbePillsWithoutSldsOrSsc() throws Exception {
+ try (var client = client()) {
+ try (var resp = client.request("GET", "/").run()) {
+ assertEquals(200, resp.getStatusCode());
+ var body = resp.getBodyAsString();
+ assertTrue(body.contains("Probes"), body);
+ assertTrue(body.contains("Details"), body);
+ assertTrue(body.contains("data-probe-id=\"checkout\""), body);
+ assertTrue(body.contains("data-probe-id=\"github\""), body);
+ assertTrue(body.contains("/js/rm-setup.js"), body);
+ assertFalse(body.contains("slds-"), body);
+ assertFalse(body.contains("ssc-"), body);
+ assertFalse(body.contains("Workflow"), body);
+ }
+ }
+ }
+
+ @Test
+ void a02_dataReturnsJsonProbes() throws Exception {
+ try (var client = client()) {
+ try (var resp = client.request("GET", "/data").header("Accept", "application/json").run()) {
+ assertEquals(200, resp.getStatusCode());
+ var body = resp.getBodyAsString();
+ assertTrue(body.contains("\"id\":\"checkout\""), body);
+ assertTrue(body.contains("\"id\":\"gpg-key\""), body);
+ }
+ }
+ }
+
+ @Test
+ void a03_installUnknownIs404() throws Exception {
+ try (var client = client()) {
+ try (var resp = client.request("POST", "/install/checkout").run()) {
+ assertEquals(404, resp.getStatusCode());
+ }
+ }
+ }
+
+ @Test
+ void a04_staticAssetsContainNoSalesforceTokens() throws IOException {
+ for (var path : List.of("/static/js/rm-setup.js", "/static/css/chrome.css", "/templates/setup.ftlh")) {
+ try (var in = SetupRestTest.class.getResourceAsStream(path)) {
+ assertNotNull(in, path);
+ var text = new String(IoUtils.readBytes(in), StandardCharsets.UTF_8);
+ assertFalse(text.contains("slds-"), path + " " + text);
+ assertFalse(text.contains("ssc-"), path + " " + text);
+ assertFalse(text.toLowerCase().contains("salesforce"), path);
+ }
+ }
+ }
+
+ private static final class FakeRunner implements ProcessRunner {
+ @Override
+ public List<String> runLines(List<String> command) {
+ return List.of();
+ }
+
+ @Override
+ public String runText(List<String> command) {
+ return "";
+ }
+
+ @Override
+ public ProcResult run(List<String> command, String stdin, Map<String, String> env) {
+ return new ProcResult(1, "");
+ }
+ }
+}
diff --git a/src/test/java/org/apache/juneau/releng/setup/SetupProbeServiceTest.java b/src/test/java/org/apache/juneau/releng/setup/SetupProbeServiceTest.java
new file mode 100644
index 0000000000..2e7f3015f6
--- /dev/null
+++ b/src/test/java/org/apache/juneau/releng/setup/SetupProbeServiceTest.java
@@ -0,0 +1,193 @@
+/*
+ * 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.
+ */
+
+package org.apache.juneau.releng.setup;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import org.apache.juneau.commons.secret.InMemorySecretStore;
+import org.apache.juneau.commons.secret.SecretStore;
+import org.apache.juneau.http.response.NotFound;
+import org.apache.juneau.releng.credential.AccountStore;
+import org.apache.juneau.releng.credential.CredentialService;
+import org.apache.juneau.releng.credential.CredentialSpec;
+import org.apache.juneau.releng.util.ProcessRunner;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class SetupProbeServiceTest {
+
+ private FakeRunner runner;
+ private SetupProbeService setup;
+ private Path repo;
+ private Path settings;
+
+ @BeforeEach
+ void setUp(@TempDir Path tmp) throws Exception {
+ repo = tmp.resolve("juneau");
+ Files.createDirectories(repo.resolve(".git"));
+ Files.writeString(repo.resolve("pom.xml"), "<project><artifactId>juneau</artifactId></project>");
+ settings = tmp.resolve("settings.xml");
+ Files.writeString(settings, """
+ <settings>
+ <servers>
+ <server>
+ <id>apache.releases.https</id>
+ <username>me</username>
+ <password>x</password>
+ </server>
+ </servers>
+ </settings>
+ """);
+ runner = new FakeRunner();
+ runner.onPath.add("mvn");
+ runner.onPath.add("git");
+ runner.onPath.add("gh");
+ runner.onPath.add("gpg");
+ runner.onPath.add("brew");
+ var stores = new EnumMap<CredentialSpec, SecretStore>(CredentialSpec.class);
+ for (var spec : CredentialSpec.values())
+ stores.put(spec, new InMemorySecretStore());
+ var creds = new CredentialService(stores, new EnumMap<>(CredentialSpec.class), new AccountStore(tmp.resolve("state")));
+ setup = new SetupProbeService(runner, creds, repo, settings);
+ }
+
+ @Test
+ void a01_inventoryHasPrereqsThenCredentials() {
+ var ids = setup.inventory().stream().map(p -> p.id).toList();
+ assertEquals(List.of("checkout", "mvn", "git", "gh", "gpg", "settings-xml", "apache", "gpg-key", "github"), ids);
+ assertEquals("pending", setup.inventory().get(0).status);
+ }
+
+ @Test
+ void a02_dataEagerPassWhenToolsAndCheckoutPresent() {
+ var data = setup.data();
+ assertEquals("brew", data.packageManager);
+ assertEquals("pass", byId(data, "checkout").status);
+ assertEquals("pass", byId(data, "mvn").status);
+ assertEquals("pass", byId(data, "settings-xml").status);
+ assertEquals("fail", byId(data, "apache").status);
+ assertTrue(byId(data, "mvn").installable);
+ assertFalse(byId(data, "checkout").installable);
+ assertEquals("apache", byId(data, "apache").credentialName);
+ assertEquals("gpg", byId(data, "gpg-key").credentialName);
+ }
+
+ @Test
+ void a03_settingsFailIsMissingServerIdNotMereFile() throws Exception {
+ Files.writeString(settings, "<settings><servers></servers></settings>");
+ var p = byId(setup.data(), "settings-xml");
+ assertEquals("fail", p.status);
+ assertTrue(p.message.contains("apache.releases.https"), p.message);
+ }
+
+ @Test
+ void a04_preferBrewWhenAptAlsoPresent() {
+ runner.onPath.add("apt-get");
+ assertEquals("brew", setup.data().packageManager);
+ }
+
+ @Test
+ void a05_aptWhenNoBrew() {
+ runner.onPath.remove("brew");
+ runner.onPath.add("apt-get");
+ var data = setup.data();
+ assertEquals("apt", data.packageManager);
+ assertTrue(byId(data, "git").copyCommand.contains("apt-get"));
+ }
+
+ @Test
+ void a06_installBrewUsesTimeoutAndClosedStdinPath() {
+ var r = setup.install("mvn");
+ assertTrue(r.ok);
+ assertTrue(runner.commands.contains(List.of("brew", "install", "maven")), runner.commands.toString());
+ assertEquals(Duration.ofMinutes(10), runner.lastTimeout);
+ }
+
+ @Test
+ void a07_installAptIsNonInteractive() {
+ runner.onPath.remove("brew");
+ runner.onPath.add("apt-get");
+ setup.install("gpg");
+ assertTrue(runner.commands.contains(List.of("apt-get", "install", "-y", "gnupg")), runner.commands.toString());
+ assertEquals("noninteractive", runner.lastEnv.get("DEBIAN_FRONTEND"));
+ }
+
+ @Test
+ void a08_installUnknownProbeIs404() {
+ assertThrows(NotFound.class, () -> setup.install("checkout"));
+ assertThrows(NotFound.class, () -> setup.install("settings-xml"));
+ assertThrows(NotFound.class, () -> setup.install("nope"));
+ }
+
+ private static SetupProbeService.Probe byId(SetupProbeService.SetupData data, String id) {
+ return data.probes.stream().filter(p -> id.equals(p.id)).findFirst().orElseThrow();
+ }
+
+ private static final class FakeRunner implements ProcessRunner {
+ final Set<String> onPath = new HashSet<>();
+ final List<List<String>> commands = new ArrayList<>();
+ List<String> lastCommand = List.of();
+ Map<String, String> lastEnv = Map.of();
+ Duration lastTimeout;
+ ProcResult installResult = new ProcResult(0, "ok");
+
+ @Override
+ public List<String> runLines(List<String> command) {
+ return List.of();
+ }
+
+ @Override
+ public String runText(List<String> command) {
+ return "";
+ }
+
+ @Override
+ public ProcResult run(List<String> command, String stdin, Map<String, String> env) {
+ commands.add(List.copyOf(command));
+ lastCommand = command;
+ if (env != null && !command.isEmpty() && !"which".equals(command.get(0)))
+ lastEnv = env;
+ if (!command.isEmpty() && "which".equals(command.get(0)))
+ return new ProcResult(onPath.contains(command.get(1)) ? 0 : 1, "");
+ return installResult;
+ }
+
+ @Override
+ public ProcResult run(List<String> command, String stdin, Map<String, String> env, Duration timeout) {
+ lastTimeout = timeout;
+ return run(command, stdin, env);
+ }
+
+ @Override
+ public ProcResult run(List<String> command, String stdin, Map<String, String> env, Consumer<String> lineSink) {
+ return run(command, stdin, env);
+ }
+ }
+}
diff --git a/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java b/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
index 43d6cc01ed..741a47f15a 100644
--- a/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
+++ b/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
@@ -43,4 +43,26 @@ class ProcessRunnerStreamTest {
assertFalse(res.ok());
assertEquals(List.of("hi"), lines);
}
+
+ @Test
+ void a03_timeoutKillsAHungProcess() {
+ var runner = new ProcessRunner.Default();
+ var start = System.nanoTime();
+ var res = runner.run(List.of("sleep", "30"), null, null, java.time.Duration.ofMillis(400));
+ var elapsedMs = (System.nanoTime() - start) / 1_000_000;
+ assertFalse(res.ok());
+ assertEquals(124, res.exitCode());
+ assertTrue(res.output().contains("Timed out"), res.output());
+ assertTrue(elapsedMs < 8_000, "timeout path hung: " + elapsedMs + "ms");
+ }
+
+ @Test
+ void a04_nullStdinClosesSoReadDoesNotHang() {
+ var runner = new ProcessRunner.Default();
+ var start = System.nanoTime();
+ var res = runner.run(List.of("sh", "-c", "read line; echo after"), null, null, java.time.Duration.ofSeconds(3));
+ var elapsedMs = (System.nanoTime() - start) / 1_000_000;
+ assertTrue(elapsedMs < 2_500, "stdin was left open: " + elapsedMs + "ms");
+ assertTrue(res.output().contains("after") || res.exitCode() != 0, res.output());
+ }
}
diff --git a/start.sh b/start.sh
index 3fa3bb1144..44566fb1c5 100755
--- a/start.sh
+++ b/start.sh
@@ -26,9 +26,8 @@
#
# The Juneau REST resources are mounted under /rest/*, e.g.:
#
-# http://127.0.0.1:8790/rest/home
+# http://127.0.0.1:8790/rest/setup
# http://127.0.0.1:8790/rest/releases
-# http://127.0.0.1:8790/rest/credentials
#
# Bind address/port come from src/main/resources/application.properties.
#