feat(onboarding): add first-run environment checker (#1290)

* feat(onboarding): add first-run environment checker

Add a preflight that checks whether the host can run Docker deploys before a
deploy fails for an avoidable reason. It verifies the Docker engine is reachable
and permitted, the Compose plugin is present, the compose directory is writable
and mounted at a matching host path, the dashboard is behind TLS, and the
compose volume has disk headroom. Each result that needs attention carries a
specific fix rather than a generic error, and the checks never block: an
operator who knows their setup can continue.

The checks run as the final step of first-boot setup and can be re-run any time
from the Recovery settings tab. A new admin-only endpoint,
GET /api/diagnostics/environment, backs both surfaces.

* fix(onboarding): distinguish unverified path mapping and support parent binds

Treat a container whose self-inspect fails as an unverified path-mapping warning
instead of a false "not containerized" pass, so an unverifiable mapping never
reads as healthy. Resolve the compose directory through the longest-prefix bind
mount and compare the host path it resolves to, so a parent bind such as
-v /opt:/opt correctly covers COMPOSE_DIR=/opt/compose instead of warning that
the directory is not bind-mounted.

* test(e2e): advance the setup wizard past the environment step in loginAs

The first-run setup helper clicked "Initialize console" and immediately waited
for the dashboard, but setup now shows an environment-preflight step before
landing the console. Click "Enter Sencho" to complete onboarding before
asserting the dashboard, so the first test on a fresh instance passes.
This commit is contained in:
Anso
2026-06-02 21:40:38 -04:00
committed by GitHub
parent a8f0ce9072
commit 5289f01bfd
11 changed files with 989 additions and 2 deletions
@@ -63,3 +63,28 @@ describe('GET /api/diagnostics', () => {
expect(res.body.config.cloud_backup_secret_key).toBeUndefined();
});
});
describe('GET /api/diagnostics/environment', () => {
it('requires authentication', async () => {
const res = await request(app).get('/api/diagnostics/environment');
expect(res.status).toBe(401);
});
it('rejects a non-admin', async () => {
const res = await request(app).get('/api/diagnostics/environment').set('Authorization', viewerAuthHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('returns the environment report for an admin', async () => {
const res = await request(app).get('/api/diagnostics/environment').set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.checks)).toBe(true);
const ids = (res.body.checks as Array<{ id: string }>).map(c => c.id);
expect(ids).toEqual(['docker_socket', 'docker_compose', 'compose_dir', 'path_mapping', 'tls', 'disk_space']);
for (const c of res.body.checks as Array<{ status: string; detail: string }>) {
expect(['pass', 'warn', 'fail']).toContain(c.status);
expect(typeof c.detail).toBe('string');
}
});
});