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
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,11 @@

# ── 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.
# Settings controls that persist an exact branch from fixed remote origin,
# defaulting to alpha when unconfigured, and can run
# `scripts/start-huabu.sh <branch> --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
# Non-interactive redeployment waits up to 300 seconds by default.
# HUABU_CANARY_READINESS_TIMEOUT_SECONDS=300
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ 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).
For a personal Alpha Canary started from a repository checkout, set `HUABU_CANARY_REDEPLOY_ENABLED=1` before `pnpm start:web`. The authenticated owner can configure an exact branch from fixed remote `origin` in Settings, compare it with the running commit, and invoke the checked-in `scripts/start-huabu.sh <branch> --non-interactive` redeployment instead of connecting through SSH. An empty configuration uses `alpha`. 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)

Expand Down
37 changes: 36 additions & 1 deletion apps/server/src/modules/security/canary-redeploy.route.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

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

Expand All @@ -10,10 +14,14 @@ import type { FastifyInstance } from 'fastify';

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

beforeEach(async () => {
delete process.env.HUABU_CANARY_REDEPLOY_ENABLED;
dataDir = mkdtempSync(join(tmpdir(), 'huabu-canary-route-'));
process.env.HUABU_DATA_DIR = dataDir;
app = Fastify({ logger: false });
await app.register(canaryRedeployRoutes, {
prefix: '/api/deployment/canary',
Expand All @@ -27,6 +35,12 @@ describe('Canary redeployment routes', () => {
} else {
process.env.HUABU_CANARY_REDEPLOY_ENABLED = originalEnabled;
}
if (originalDataDir === undefined) {
delete process.env.HUABU_DATA_DIR;
} else {
process.env.HUABU_DATA_DIR = originalDataDir;
}
rmSync(dataDir, { recursive: true, force: true });
});

it('lets the local owner inspect a disabled capability', async () => {
Expand All @@ -39,6 +53,7 @@ describe('Canary redeployment routes', () => {
available: false,
reason: 'disabled',
branch: 'alpha',
configuredBranch: null,
});
});

Expand All @@ -63,7 +78,27 @@ describe('Canary redeployment routes', () => {
const unavailable = await app.inject({
method: 'POST',
url: '/api/deployment/canary/redeploy',
payload: {},
payload: { expectedBranch: 'alpha' },
});
expect(unavailable.statusCode).toBe(503);
expect(unavailable.json()).toMatchObject({
code: 'canary_redeploy_unavailable',
});
});

it('validates branch configuration before capability checks', async () => {
const malformed = await app.inject({
method: 'PUT',
url: '/api/deployment/canary/config',
payload: { branch: '' },
});
expect(malformed.statusCode).toBe(400);
expect(malformed.json()).toMatchObject({ code: 'validation_failed' });

const unavailable = await app.inject({
method: 'PUT',
url: '/api/deployment/canary/config',
payload: { branch: 'x/alpha' },
});
expect(unavailable.statusCode).toBe(503);
expect(unavailable.json()).toMatchObject({
Expand Down
120 changes: 85 additions & 35 deletions apps/server/src/modules/security/canary-redeploy.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,109 @@
// Licensed under the MIT license.

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

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

import type { FastifyPluginAsync } from 'fastify';
import type { FastifyPluginAsync, FastifyReply } from 'fastify';

function sendCanaryError(
reply: FastifyReply,
error: unknown,
fallback: string,
) {
if (error instanceof CanaryRedeployError) {
const status =
error.code === 'operation_in_progress' || error.code === 'stale_branch'
? 409
: error.code === 'branch_invalid'
? 400
: error.code === 'branch_unavailable'
? 422
: error.code === 'config_invalid'
? 500
: 503;
return reply.status(status).send({
message: error.message,
code: `canary_${error.code}`,
});
}
return reply.status(500).send({
message: fallback,
code: 'canary_internal_error',
});
}

const canaryRedeployRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', async (request, reply) => {
if (!isOwnerRequest(request)) {
return reply.status(403).send({
message: 'Forbidden: Canary redeployment requires owner authorization',
});
}
});

app.get<{ Reply: ApiResult<CanaryRedeployStatusResponse> }>(
'/',
async (request, reply) => {
if (!isOwnerRequest(request)) {
return reply.status(403).send({
message:
'Forbidden: Canary redeployment requires owner authorization',
});
try {
return await getCanaryRedeployStatus();
} catch (error) {
request.log.error({ err: error }, 'Unable to read Canary status');
return sendCanaryError(
reply,
error,
'Unable to load Canary redeployment status',
);
}
return getCanaryRedeployStatus();
},
);

app.post<{
Body: CanaryRedeployRequest;
app.put<{
Body: CanaryRedeployConfigUpdate;
Reply: ApiResult<CanaryRedeployStatusResponse>;
}>('/check', async (request, reply) => {
if (!isOwnerRequest(request)) {
return reply.status(403).send({
message: 'Forbidden: Canary redeployment requires owner authorization',
}>('/config', async (request, reply) => {
const parsed = canaryRedeployConfigUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
message:
parsed.error.issues[0]?.message ??
'Invalid Canary branch configuration',
code: 'validation_failed',
});
}
const parsed = canaryRedeployRequestSchema.safeParse(request.body);
try {
return await setCanaryRedeployConfig(parsed.data);
} catch (error) {
request.log.warn({ err: error }, 'Unable to save Canary branch');
return sendCanaryError(
reply,
error,
'Unable to save Canary branch configuration',
);
}
});

app.post<{
Body: CanaryCheckRequest;
Reply: ApiResult<CanaryRedeployStatusResponse>;
}>('/check', async (request, reply) => {
const parsed = canaryCheckRequestSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
message:
Expand All @@ -52,22 +116,14 @@ const canaryRedeployRoutes: FastifyPluginAsync = async (app) => {
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',
});
return sendCanaryError(reply, error, 'Unable to check Canary branch');
}
});

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({
Expand All @@ -77,21 +133,15 @@ const canaryRedeployRoutes: FastifyPluginAsync = async (app) => {
});
}
try {
const status = await requestCanaryRedeploy();
const status = await requestCanaryRedeploy(parsed.data.expectedBranch);
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',
});
return sendCanaryError(
reply,
error,
'Canary redeployment is unavailable',
);
}
});
};
Expand Down
Loading
Loading