Skip to content
Merged
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: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@
# HUABU_BASIC_AUTH_USER=
# HUABU_BASIC_AUTH_PASS=

# ── Personal Alpha Canary redeployment ──
# Source-run `pnpm start:web` only. When enabled, the authenticated owner sees
# Settings controls that compare the running commit with origin/alpha and can
# run `scripts/start-huabu.sh alpha --non-interactive` on this host. The script
# updates the checkout in place and may leave the service offline on failure;
# this is a personal-development convenience, not a production deployer.
# HUABU_CANARY_REDEPLOY_ENABLED=1

# ── Storage (restart required) ──
# Structured records: disk (default), sqlite or postgres. The two axes are
# independent, so every pairing of an implemented record backend with an
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ Then run `pnpm start:web`. Huabu rejects a non-loopback bind when allowed hosts

Huabu currently serves HTTP. Use a trusted private network or terminate HTTPS with deployment infrastructure such as Caddy, Nginx, Tailscale Serve, or a cloud load balancer. Do not put a Basic Auth deployment on an untrusted network without transport encryption.

For a personal Alpha Canary started from a repository checkout, set `HUABU_CANARY_REDEPLOY_ENABLED=1` before `pnpm start:web`. The authenticated owner can then compare the running commit with `origin/alpha` and invoke the checked-in `scripts/start-huabu.sh alpha --non-interactive` redeployment from Settings instead of connecting through SSH. This helper updates the checkout in place and does not provide rollback or service recovery; see [Alpha Canary deployment](docs/architecture/canary-deployment.md).

### Local quality checks (optional)

The repository ships opt-in git hooks that give you fast feedback before
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import integrationsRoutes from './modules/integrations/integrations.route.js';
import interactiveViewRoutes from './modules/interactive-view/interactive-view.route.js';
import { isPublicRfsSkillBootstrapRequest } from './modules/remote_fs/public-skill.js';
import rfsRoutes from './modules/remote_fs/rfs.route.js';
import canaryRedeployRoutes from './modules/security/canary-redeploy.route.js';
import { createCorsOptions } from './modules/security/cors.js';
import deploymentRoutes from './modules/security/deployment.route.js';
import {
Expand Down Expand Up @@ -258,6 +259,9 @@ app.register(artifactRoute, { prefix: '/api/canvas' });
app.register(llmRoutes, { prefix: '/api/llm' });
app.register(integrationsRoutes, { prefix: '/api/integrations' });
app.register(deploymentRoutes, { prefix: '/api/deployment' });
app.register(canaryRedeployRoutes, {
prefix: '/api/deployment/canary',
});
app.register(interactiveViewRoutes, { prefix: '/api/interactive-views' });
app.register(skillsRoutes, { prefix: '/api/skills' });
app.register(workspaceRoutes, { prefix: '/api/workspace' });
Expand Down
73 changes: 73 additions & 0 deletions apps/server/src/modules/security/canary-redeploy.route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import Fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import canaryRedeployRoutes from './canary-redeploy.route.js';

import type { FastifyInstance } from 'fastify';

describe('Canary redeployment routes', () => {
let app: FastifyInstance;
const originalEnabled = process.env.HUABU_CANARY_REDEPLOY_ENABLED;

beforeEach(async () => {
delete process.env.HUABU_CANARY_REDEPLOY_ENABLED;
app = Fastify({ logger: false });
await app.register(canaryRedeployRoutes, {
prefix: '/api/deployment/canary',
});
});

afterEach(async () => {
await app.close();
if (originalEnabled === undefined) {
delete process.env.HUABU_CANARY_REDEPLOY_ENABLED;
} else {
process.env.HUABU_CANARY_REDEPLOY_ENABLED = originalEnabled;
}
});

it('lets the local owner inspect a disabled capability', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/deployment/canary',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
available: false,
reason: 'disabled',
branch: 'alpha',
});
});

it('rejects non-owner callers', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/deployment/canary',
remoteAddress: '192.0.2.10',
});
expect(response.statusCode).toBe(403);
});

it('validates action request bodies and reports unavailable redeployment', async () => {
const malformed = await app.inject({
method: 'POST',
url: '/api/deployment/canary/redeploy',
payload: { branch: 'main' },
});
expect(malformed.statusCode).toBe(400);
expect(malformed.json()).toMatchObject({ code: 'validation_failed' });

const unavailable = await app.inject({
method: 'POST',
url: '/api/deployment/canary/redeploy',
payload: {},
});
expect(unavailable.statusCode).toBe(503);
expect(unavailable.json()).toMatchObject({
code: 'canary_redeploy_unavailable',
});
});
});
99 changes: 99 additions & 0 deletions apps/server/src/modules/security/canary-redeploy.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import {
canaryRedeployRequestSchema,
type ApiResult,
type CanaryRedeployRequest,
type CanaryRedeployStatusResponse,
} from '@huabu/shared';

import {
checkCanaryRemote,
getCanaryRedeployStatus,
requestCanaryRedeploy,
} from './canary-redeploy.js';
import { isOwnerRequest } from './owner.js';

import type { FastifyPluginAsync } from 'fastify';

const canaryRedeployRoutes: FastifyPluginAsync = async (app) => {
app.get<{ Reply: ApiResult<CanaryRedeployStatusResponse> }>(
'/',
async (request, reply) => {
if (!isOwnerRequest(request)) {
return reply.status(403).send({
message:
'Forbidden: Canary redeployment requires owner authorization',
});
}
return getCanaryRedeployStatus();
},
);

app.post<{
Body: CanaryRedeployRequest;
Reply: ApiResult<CanaryRedeployStatusResponse>;
}>('/check', async (request, reply) => {
if (!isOwnerRequest(request)) {
return reply.status(403).send({
message: 'Forbidden: Canary redeployment requires owner authorization',
});
}
const parsed = canaryRedeployRequestSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
message:
parsed.error.issues[0]?.message ?? 'Invalid Canary check request',
code: 'validation_failed',
});
}
try {
return await checkCanaryRemote();
} catch (error) {
request.log.warn({ err: error }, 'Canary update check failed');
return reply.status(502).send({
message: 'Unable to resolve origin/alpha',
code: 'canary_check_failed',
});
}
});

app.post<{
Body: CanaryRedeployRequest;
Reply: ApiResult<CanaryRedeployStatusResponse>;
}>('/redeploy', async (request, reply) => {
if (!isOwnerRequest(request)) {
return reply.status(403).send({
message: 'Forbidden: Canary redeployment requires owner authorization',
});
}
const parsed = canaryRedeployRequestSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
message:
parsed.error.issues[0]?.message ?? 'Invalid Canary redeploy request',
code: 'validation_failed',
});
}
try {
const status = await requestCanaryRedeploy();
return reply.status(202).send(status);
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (message === 'Canary redeployment is already in progress') {
return reply.status(409).send({
message,
code: 'canary_redeploy_in_progress',
});
}
request.log.error({ err: error }, 'Unable to start Canary redeployment');
return reply.status(503).send({
message: 'Canary redeployment is unavailable',
code: 'canary_redeploy_unavailable',
});
}
});
};

export default canaryRedeployRoutes;
160 changes: 160 additions & 0 deletions apps/server/src/modules/security/canary-redeploy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { execFileSync, spawnSync } from 'node:child_process';
import {
chmodSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import {
checkCanaryRemote,
getCanaryRedeployStatus,
resetCanaryRedeployStateForTest,
resolveCanaryCapability,
writeCanaryRedeployResult,
} from './canary-redeploy.js';

describe('Canary redeployment service', () => {
let root: string;
let remote: string;
let dataDir: string;
const originalEnv = {
enabled: process.env.HUABU_CANARY_REDEPLOY_ENABLED,
repoRoot: process.env.HUABU_REPO_ROOT,
deployedSha: process.env.HUABU_DEPLOYED_SHA,
dataDir: process.env.HUABU_DATA_DIR,
};

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'huabu-canary-repo-'));
remote = mkdtempSync(join(tmpdir(), 'huabu-canary-remote-'));
dataDir = mkdtempSync(join(tmpdir(), 'huabu-canary-data-'));
mkdirSync(join(root, 'scripts'));
for (const name of ['start-huabu.sh', 'canary-redeploy-runner.mjs']) {
const file = join(root, 'scripts', name);
writeFileSync(file, '#!/usr/bin/env bash\nexit 0\n');
chmodSync(file, 0o755);
}

execFileSync('git', ['init', '--bare', remote]);
execFileSync('git', ['init', '-b', 'alpha'], { cwd: root });
execFileSync('git', ['config', 'user.email', 'canary@example.test'], {
cwd: root,
});
execFileSync('git', ['config', 'user.name', 'Canary Test'], { cwd: root });
writeFileSync(join(root, 'README.md'), 'canary\n');
execFileSync('git', ['add', '.'], { cwd: root });
execFileSync('git', ['commit', '-m', 'Initial Canary revision'], {
cwd: root,
});
execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: root });
execFileSync('git', ['push', '-u', 'origin', 'alpha'], { cwd: root });

const sha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: root,
encoding: 'utf8',
}).trim();
process.env.HUABU_CANARY_REDEPLOY_ENABLED = '1';
process.env.HUABU_REPO_ROOT = root;
process.env.HUABU_DEPLOYED_SHA = sha;
process.env.HUABU_DATA_DIR = dataDir;
resetCanaryRedeployStateForTest();
});

afterEach(() => {
const restore = (key: string, value: string | undefined) => {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
};
restore('HUABU_CANARY_REDEPLOY_ENABLED', originalEnv.enabled);
restore('HUABU_REPO_ROOT', originalEnv.repoRoot);
restore('HUABU_DEPLOYED_SHA', originalEnv.deployedSha);
restore('HUABU_DATA_DIR', originalEnv.dataDir);
resetCanaryRedeployStateForTest();
rmSync(root, { recursive: true, force: true });
rmSync(remote, { recursive: true, force: true });
rmSync(dataDir, { recursive: true, force: true });
});

it('requires explicit enablement and executable repository scripts', () => {
expect(resolveCanaryCapability()).toMatchObject({
available: true,
reason: 'available',
repoRoot: root,
});

delete process.env.HUABU_CANARY_REDEPLOY_ENABLED;
expect(resolveCanaryCapability()).toMatchObject({
available: false,
reason: 'disabled',
});
});

it('compares the startup revision with origin/alpha', async () => {
const status = await checkCanaryRemote();
expect(status).toMatchObject({
available: true,
branch: 'alpha',
updateAvailable: false,
runningSha: status.remoteSha,
});
expect(status.checkedAt).toEqual(expect.any(Number));
});

it('persists only the bounded redeployment result contract', async () => {
await writeCanaryRedeployResult({
state: 'failed',
startedAt: 10,
completedAt: 20,
exitCode: 1,
message: 'Redeploy script exited with status 1',
});

await expect(getCanaryRedeployStatus()).resolves.toMatchObject({
redeploy: {
state: 'failed',
exitCode: 1,
},
});
});

it('records a detached runner failure without exposing command output', () => {
const hook = join(root, 'failing-hook.sh');
const statusPath = join(dataDir, 'runner-status.json');
const logPath = join(dataDir, 'runner.log');
writeFileSync(hook, '#!/usr/bin/env bash\necho private-output\nexit 7\n');
chmodSync(hook, 0o755);

const runner = join(
process.cwd(),
'..',
'..',
'scripts',
'canary-redeploy-runner.mjs',
);
const result = spawnSync(
process.execPath,
[runner, hook, statusPath, logPath, '100'],
{ encoding: 'utf8' },
);

expect(result.status).toBe(1);
const status = readFileSync(statusPath, 'utf8');
expect(JSON.parse(status)).toMatchObject({
state: 'failed',
startedAt: 100,
exitCode: 7,
});
expect(status).not.toContain('private-output');
expect(readFileSync(logPath, 'utf8')).toContain('private-output');
});
});
Loading
Loading