feat: migrate todo note to nc01 github storage

This commit is contained in:
Codex
2026-07-10 03:47:30 +02:00
parent e11c140b09
commit 0184bd7334
57 changed files with 3738 additions and 350 deletions
+7 -5
View File
@@ -4,6 +4,8 @@ import { rootPath } from "./config";
export type CiArtifactStatus = "supported" | "blocked";
export type CiSourceBuildProducer = "ci publish-backend-core" | "ci publish-user-service" | "platform-infra pipelines-as-code";
export type CiCatalogArtifact = CiSourceBuildCatalogArtifact | CiUpstreamImageCatalogArtifact;
export interface CiCatalog {
@@ -27,7 +29,7 @@ export interface CiSourceBuildCatalogArtifact {
serviceId: string;
kind: "source-build";
status: CiArtifactStatus;
producer: "ci publish-backend-core" | "ci publish-user-service";
producer: CiSourceBuildProducer;
source: {
repo: string;
dockerfile: string;
@@ -132,8 +134,8 @@ function validateSourceBuildArtifact(item: Record<string, unknown>, index: numbe
const status = stringField(item, "status", path);
if (status !== "supported" && status !== "blocked") throw new Error(`${path}.status must be supported or blocked`);
const producer = stringField(item, "producer", path);
if (producer !== "ci publish-backend-core" && producer !== "ci publish-user-service") {
throw new Error(`${path}.producer must be ci publish-backend-core or ci publish-user-service`);
if (producer !== "ci publish-backend-core" && producer !== "ci publish-user-service" && producer !== "platform-infra pipelines-as-code") {
throw new Error(`${path}.producer must be ci publish-backend-core, ci publish-user-service, or platform-infra pipelines-as-code`);
}
const artifact: CiSourceBuildCatalogArtifact = {
serviceId: stringField(item, "serviceId", path),
@@ -238,9 +240,9 @@ export function findCiCatalogArtifact(serviceId: string): CiCatalogArtifact | nu
return loadCiCatalog().artifacts.find((artifact) => artifact.serviceId === serviceId) ?? null;
}
export function supportedSourceBuildArtifactIds(): string[] {
export function supportedSourceBuildArtifactIds(producer?: CiSourceBuildProducer): string[] {
return loadCiCatalog().artifacts
.filter((artifact): artifact is CiSourceBuildCatalogArtifact => artifact.kind === "source-build" && artifact.status === "supported")
.filter((artifact): artifact is CiSourceBuildCatalogArtifact => artifact.kind === "source-build" && artifact.status === "supported" && (producer === undefined || artifact.producer === producer))
.map((artifact) => artifact.serviceId);
}
+2 -1
View File
@@ -27,7 +27,7 @@ import { makeRunId, resolveDeployDevManifest, runDevE2E } from "./dev-e2e";
import { ciHelp, requireRunId } from "./help";
import { install, installAsync, installStatus } from "./install";
import { logs } from "./logs";
import { blockedArtifactResult, blockedReason, boolFlag, ciCleanupFailedPodsOptions, ciCleanupRunsOptions, ciLogsOptions, isHelpArg, numberOption, publishTransportOption, requireDesiredRef, requireFullCommit, requireRepoRelativePath, requireRevision, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options";
import { assertUserServicePublishProducer, blockedArtifactResult, blockedReason, boolFlag, ciCleanupFailedPodsOptions, ciCleanupRunsOptions, ciLogsOptions, isHelpArg, numberOption, publishTransportOption, requireDesiredRef, requireFullCommit, requireRepoRelativePath, requireRevision, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options";
import { backendCoreArtifactSourceHostPath, userServiceArtifactSourceHostPath } from "./pipelinerun";
import { publishBackendCoreArtifact, publishUserServiceArtifact, run } from "./publish";
import { ciTarget, ciTargetSourceSummary, providerIdOption } from "./types";
@@ -135,6 +135,7 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
if (artifact.status === "blocked") {
return blockedArtifactResult(artifact, commit, blockedReason(artifact));
}
assertUserServicePublishProducer(artifact);
const repoUrl = artifact.source.repo;
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`);
const boundaryBlock = userServicePublishBoundaryBlock(config, serviceId, commit, artifact);
+1 -1
View File
@@ -117,7 +117,7 @@ export function ciHelp(): Record<string, unknown> {
tekton: "D601 Tekton PipelineRun through backend-core/provider control plane",
directDocker: "repo-owned Docker artifact publish without backend-core/database dispatch; no deploy apply, rollout, restart, or active-task mutation",
},
supportedServices: supportedSourceBuildArtifactIds().filter((serviceId) => serviceId !== "backend-core"),
supportedServices: supportedSourceBuildArtifactIds("ci publish-user-service"),
blockedServices: blockedCatalogArtifactIds(),
registry: "127.0.0.1:5000/unidesk/<service-id>:<commit>",
outputFields: ["serviceId", "sourceCommit", "sourceRepo", "dockerfile", "imageRef", "tag", "digest", "digestRef"],
+8
View File
@@ -280,6 +280,14 @@ export function resolveCatalogArtifact(serviceId: string): CiCatalogArtifact {
return artifact;
}
export function assertUserServicePublishProducer(artifact: CiSourceBuildCatalogArtifact): void {
if (artifact.producer === "ci publish-user-service") return;
if (artifact.producer === "platform-infra pipelines-as-code") {
throw new Error(`${artifact.serviceId} is produced by platform-infra pipelines-as-code; use bun scripts/cli.ts platform-infra pipelines-as-code plan --target NC01 --consumer unidesk-host`);
}
throw new Error(`${artifact.serviceId} is produced by ${artifact.producer}; use the owning producer command`);
}
export function blockedArtifactResult(artifact: CiUpstreamImageCatalogArtifact | CiSourceBuildCatalogArtifact, commit: string, note: string): Record<string, unknown> {
const base = {
ok: false,
+2 -1
View File
@@ -25,7 +25,7 @@ import { runSshCommandCapture } from "../ssh";
import type { ArtifactSummaryContext, CiOptions, CiPublishBackendCoreOptions, CiPublishUserServiceArtifactOptions, PublishPreflightTransport } from "./types";
import { artifactSummaryDefaults } from "./artifact-summary";
import { publishUserServiceArtifactDirectDocker } from "./direct-docker";
import { blockedArtifactResult, blockedReason, numberOption, publishTransportOption, repoConnectivityProbeUrl, repoNeedsGithubSshIdentity, repoSshUrl, requireFullCommit, requireRepoRelativePath, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options";
import { assertUserServicePublishProducer, blockedArtifactResult, blockedReason, numberOption, publishTransportOption, repoConnectivityProbeUrl, repoNeedsGithubSshIdentity, repoSshUrl, requireFullCommit, requireRepoRelativePath, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options";
import { backendCoreArtifactPipelineRunManifest, backendCoreArtifactSourceHostPath, pipelineRunManifest, userServiceArtifactPipelineRunManifest, userServiceArtifactSourceHostPath } from "./pipelinerun";
import { pipelineRunWaitSucceeded, readPipelineRunCondition, remoteCreatePipelineRun, waitForPipelineRun } from "./pipelinerun-runtime";
import { publishPreflightControlChannelOrder, publishPreflightFailedScopes } from "./preflight";
@@ -238,6 +238,7 @@ export async function runCiPublishUserServiceDryRunPreflight(
if (artifact.status === "blocked") {
return blockedArtifactResult(artifact, commit, blockedReason(artifact));
}
assertUserServicePublishProducer(artifact);
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`);
const boundaryBlock = userServicePublishBoundaryBlock(config, serviceId, commit, artifact);
if (boundaryBlock !== null) return boundaryBlock;
@@ -2,7 +2,7 @@
// Responsibility: shared types, config helpers and text render utilities for web-probe sentinel CI/CD.
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { isAbsolute, join } from "node:path";
import { resolveCliChildJsonCommandResult } from "./cli-child-json-recovery";
import type { CommandResult } from "./command";
import { repoRoot, rootPath } from "./config";
@@ -320,7 +320,7 @@ export function monitorWebBuildkitStatePlan(cicd: Record<string, unknown>): Reco
}
export function secretSourcePaths(sourceRef: string): string[] {
if (sourceRef.startsWith(".env/")) return ownerFileSourcePaths(sourceRef);
if (isAbsolute(sourceRef)) return [sourceRef];
const paths = [join(repoRoot, ".state", "secrets", sourceRef)];
const marker = "/.worktree/";
const index = repoRoot.indexOf(marker);
@@ -328,14 +328,6 @@ export function secretSourcePaths(sourceRef: string): string[] {
return [...new Set(paths)];
}
export function ownerFileSourcePaths(sourceRef: string): string[] {
if (sourceRef.includes("..") || sourceRef.includes("\0")) return [];
const marker = "/.worktree/";
const index = repoRoot.indexOf(marker);
const roots = index >= 0 ? [repoRoot.slice(0, index), repoRoot] : [repoRoot];
return [...new Set(roots.map((root) => join(root, sourceRef)))];
}
export function parseEnvFile(textValue: string): Record<string, string> {
const values: Record<string, string> = {};
for (const rawLine of textValue.split(/\r?\n/u)) {
+4 -12
View File
@@ -7,7 +7,7 @@
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { dirname, isAbsolute, join } from "node:path";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand, type CommandResult } from "../command";
import { startJob } from "../jobs";
@@ -739,7 +739,7 @@ export function readLocalPostgresPasswordMaterial(input: { sourceRef: string; so
}
export function localSecretSourcePaths(sourceRef: string): string[] {
if (sourceRef.startsWith(".env/")) return ownerFileSourcePaths(sourceRef);
if (isAbsolute(sourceRef)) return [sourceRef];
const marker = "/.worktree/";
const index = repoRoot.indexOf(marker);
const paths = index >= 0
@@ -1286,7 +1286,7 @@ export function externalPostgresSecretSourceRoot(spec: HwlabRuntimeLaneSpec): st
}
export function readSecretSourceValue(secretRoot: string, sourceRef: string, key: string): { ok: true; value: string; sourcePath: string } | { ok: false; sourcePath: string } {
const sourcePath = join(secretRoot, sourceRef);
const sourcePath = isAbsolute(sourceRef) ? sourceRef : join(secretRoot, sourceRef);
if (!existsSync(sourcePath)) return { ok: false, sourcePath };
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
const value = values[key];
@@ -1295,7 +1295,7 @@ export function readSecretSourceValue(secretRoot: string, sourceRef: string, key
}
export function secretSourcePaths(sourceRef: string): string[] {
if (sourceRef.startsWith(".env/")) return ownerFileSourcePaths(sourceRef);
if (isAbsolute(sourceRef)) return [sourceRef];
const paths = [join(repoRoot, ".state", "secrets", sourceRef)];
const marker = "/.worktree/";
const index = repoRoot.indexOf(marker);
@@ -1303,14 +1303,6 @@ export function secretSourcePaths(sourceRef: string): string[] {
return [...new Set(paths)];
}
function ownerFileSourcePaths(sourceRef: string): string[] {
if (sourceRef.includes("..") || sourceRef.includes("\0")) return [];
const marker = "/.worktree/";
const index = repoRoot.indexOf(marker);
const roots = index >= 0 ? [repoRoot.slice(0, index), repoRoot] : [repoRoot];
return [...new Set(roots.map((root) => join(root, sourceRef)))];
}
export function displayRepoPath(path: string): string {
const normalizedRoot = repoRoot.replace(/\/+$/u, "");
if (path === normalizedRoot) return ".";
+23 -17
View File
@@ -4,20 +4,17 @@ import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot } from "./config";
import { jsonByteLength, previewJson } from "./preview";
// Todo Note misleading-404 rewrite (issue #198) — CLI-side interim workaround
// for the upstream todo_note catch-all handler. The upstream repo
// (https://gitee.com/Lyon1998/todo_note) wraps every unregistered path in
// Todo Note misleading-404 rewrite (issue #198) for legacy deployments.
// The historical service wrapped every unregistered path in
// `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,
// which makes agents and humans misdiagnose a path typo as a write lock.
// When the upstream fix lands, this rewrite becomes a no-op: the upstream
// body will not match `isTodoNoteMisleadingRouteNotFoundBody` anymore and
// The vendored service returns normal route errors, so this becomes a no-op:
// its body does not match `isTodoNoteMisleadingRouteNotFoundBody` and
// the CLI will pass the upstream response through unchanged.
//
// Source of truth for the writable endpoint list below is
// docs/reference/microservices.md "Todo Note On Main Server" and
// apps/server/src/server.ts in the upstream todo_note repo. If the
// upstream registered routes change, update TODO_NOTE_WRITABLE_ENDPOINTS
// and TODO_NOTE_ACTION_TYPES in lock-step.
// src/components/microservices/todo-note/src/index.ts. Keep the catalog and
// action types aligned with that service.
interface TodoNoteEndpoint {
method: string;
@@ -38,6 +35,8 @@ export const TODO_NOTE_ACTION_TYPES = [
] as const;
export const TODO_NOTE_WRITABLE_ENDPOINTS: readonly TodoNoteEndpoint[] = [
{ method: "POST", path: "/api/storage/import", hint: "Import a confirmed snapshot or gzip-packed migration request." },
{ method: "POST", path: "/api/storage/migrate", hint: "Persist the current runtime index to the configured GitHub store." },
{ method: "POST", path: "/api/instances", hint: "Create a new todo list; body {name}." },
{ method: "DELETE", path: "/api/instances/:instanceId", hint: "Delete a todo list." },
{
@@ -673,6 +672,15 @@ function methodOption(args: string[], hasBody = false): string {
return method;
}
export function parseMicroserviceProxyRequest(args: string[]): { body: unknown | undefined; method: string; checkPath: boolean } {
const body = requestBodyOption(args);
return {
body,
method: methodOption(args, body !== undefined),
checkPath: hasFlag(args, "--check-path"),
};
}
export function summarizeMicroserviceProxyResponse(response: unknown, args: string[]): unknown {
const full = args.includes("--full");
const raw = args.includes("--raw");
@@ -969,16 +977,14 @@ export async function runMicroserviceCommand(_config: UniDeskConfig, args: strin
if (action === "proxy") {
const id = requireId(idArg, "microservice proxy");
const path = requireProxyPath(pathArg);
const body = requestBodyOption(args);
const request = parseMicroserviceProxyRequest(args);
const full = hasFlag(args, "--full");
const raw = hasFlag(args, "--raw");
const checkPath = hasFlag(args, "--check-path");
const method = methodOption(args, body !== undefined);
if (checkPath) {
if (request.checkPath) {
if (id !== "todo-note") {
return {
ok: false,
command: `microservice proxy ${id} ${path} --method ${method} --check-path`,
command: `microservice proxy ${id} ${path} --method ${request.method} --check-path`,
data: {
ok: false,
error: `--check-path currently only supports service=todo-note; got ${id}`,
@@ -987,12 +993,12 @@ export async function runMicroserviceCommand(_config: UniDeskConfig, args: strin
},
};
}
return runTodoNoteCheckPath(method, path);
return runTodoNoteCheckPath(request.method, path);
}
const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000);
const maxResponseBytes = full ? Math.min(Math.max(maxBodyBytes, 120_000), 5_000_000) : Math.min(Math.max(maxBodyBytes * 3, 240_000), 1_500_000);
const fetched = coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method, body, maxResponseBytes });
const rewritten = rewriteTodoNoteMisleadingRouteNotFound(id, method, path, fetched);
const fetched = coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method: request.method, body: request.body, maxResponseBytes });
const rewritten = rewriteTodoNoteMisleadingRouteNotFound(id, request.method, path, fetched);
return summarizeMicroserviceProxyResponse(rewritten, args);
}
throw new Error("microservice command must be one of: list, status, health, diagnostics, tunnel-self-test, proxy");
@@ -232,10 +232,19 @@ apply_action() {
--from-literal="$UNIDESK_PAC_WEBHOOK_SECRET_KEY=$UNIDESK_PAC_WEBHOOK_SECRET" \
--dry-run=client -o yaml | kubectl apply -f - >/dev/null
repository_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null
argo_bootstrap=false
if [ -n "${UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64:-}" ]; then
argo_tmp=$(mktemp)
printf '%s' "$UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64" | base64 -d >"$argo_tmp"
kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f "$argo_tmp" >/dev/null
rm -f "$argo_tmp"
kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" annotate application "$UNIDESK_PAC_ARGO_APPLICATION" argocd.argoproj.io/refresh=hard --overwrite >/dev/null
argo_bootstrap=true
fi
hook_id=$(ensure_webhook)
ready=false
if wait_ready; then ready=true; fi
printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")"
printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"argoBootstrap":%s,"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$argo_bootstrap" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")"
}
condition_status() {
@@ -701,15 +710,24 @@ NODE
runtime_summary() {
app_file=$(mktemp)
params_file=$(mktemp)
kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" get application "$UNIDESK_PAC_ARGO_APPLICATION" -o json >"$app_file" 2>/dev/null || printf '{}' >"$app_file"
target=$(node - "$app_file" <<'NODE'
printf '%s' "$UNIDESK_PAC_PARAMS_JSON" >"$params_file"
target=$(node - "$app_file" "$params_file" <<'NODE'
const fs = require('node:fs');
const app = JSON.parse(fs.readFileSync(process.argv[2], 'utf8') || '{}');
const params = JSON.parse(fs.readFileSync(process.argv[3], 'utf8') || '{}');
const prefix = process.env.UNIDESK_PAC_CONSUMER_ID === 'unidesk-host' ? 'unidesk_host_' : '';
const param = (name) => params[`${prefix}${name}`] ?? params[name];
if (param('runtime_namespace') && param('runtime_deployment')) {
process.stdout.write(`${param('runtime_namespace')}\t${param('runtime_deployment')}`);
process.exit(0);
}
const resource = (app.status?.resources || []).find((item) => item.kind === 'Deployment' && item.namespace && item.name);
if (resource) process.stdout.write(`${resource.namespace}\t${resource.name}`);
NODE
)
rm -f "$app_file"
rm -f "$app_file" "$params_file"
if [ -z "$target" ]; then
printf '{}'
return
@@ -759,8 +777,10 @@ const pipelines = JSON.parse(fs.readFileSync(process.argv[3], 'utf8') || '[]');
const latest = Array.isArray(pipelines) ? pipelines[0] || {} : {};
const sourceCommit = latest.sourceCommit || null;
const tag = sourceCommit ? String(sourceCommit).slice(0, 12) : '';
const imageRepository = typeof params.image_repository === 'string' ? params.image_repository : '';
const probeBase = typeof params.registry_probe_base === 'string' ? params.registry_probe_base.replace(/\/+$/u, '') : '';
const prefix = process.env.UNIDESK_PAC_CONSUMER_ID === 'unidesk-host' ? 'unidesk_host_' : '';
const param = (name) => params[`${prefix}${name}`] ?? params[name];
const imageRepository = typeof param('image_repository') === 'string' ? param('image_repository') : '';
const probeBase = typeof param('registry_probe_base') === 'string' ? param('registry_probe_base').replace(/\/+$/u, '') : '';
let registryUrl = '';
if (imageRepository && tag) {
const firstSlash = imageRepository.indexOf('/');
@@ -777,9 +797,14 @@ line('registryProbeBase', probeBase);
line('sourceCommit', sourceCommit || '');
line('tag', tag);
line('registryUrl', registryUrl);
line('sentinelId', params.sentinel_id || '');
line('gitopsBranch', params.gitops_branch || '');
line('gitopsManifestPath', params.gitops_manifest_path || '');
line('sentinelId', param('sentinel_id') || '');
line('gitopsBranch', param('gitops_branch') || '');
line('gitopsManifestPath', param('gitops_manifest_path') || '');
line('runtimeNamespace', param('runtime_namespace') || '');
line('runtimeService', param('runtime_service') || '');
line('runtimeServicePort', param('runtime_service_port') || '');
line('healthPath', param('health_path') || '');
line('healthUrl', param('health_url') || '');
NODE
)
image_repository=$(printf '%s\n' "$probe_env" | sed -n 's/^imageRepository=//p' | base64 -d 2>/dev/null || true)
@@ -790,6 +815,11 @@ NODE
sentinel_id=$(printf '%s\n' "$probe_env" | sed -n 's/^sentinelId=//p' | base64 -d 2>/dev/null || true)
gitops_branch=$(printf '%s\n' "$probe_env" | sed -n 's/^gitopsBranch=//p' | base64 -d 2>/dev/null || true)
gitops_manifest_path=$(printf '%s\n' "$probe_env" | sed -n 's/^gitopsManifestPath=//p' | base64 -d 2>/dev/null || true)
runtime_namespace=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeNamespace=//p' | base64 -d 2>/dev/null || true)
runtime_service=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeService=//p' | base64 -d 2>/dev/null || true)
runtime_service_port=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeServicePort=//p' | base64 -d 2>/dev/null || true)
health_path=$(printf '%s\n' "$probe_env" | sed -n 's/^healthPath=//p' | base64 -d 2>/dev/null || true)
health_url=$(printf '%s\n' "$probe_env" | sed -n 's/^healthUrl=//p' | base64 -d 2>/dev/null || true)
registry_present=false
registry_digest=""
if [ -n "$registry_url" ]; then
@@ -804,6 +834,26 @@ NODE
fi
registry_present_json=false
if [ "$registry_present" = true ]; then registry_present_json=true; fi
health_status=""
if [ -n "$health_url" ]; then
if [ -n "$runtime_namespace" ] && [ -n "$runtime_service" ] && [ -n "$runtime_service_port" ] && [ -n "$health_path" ]; then
health_file=$(mktemp)
if kubectl get --raw "/api/v1/namespaces/$runtime_namespace/services/$runtime_service:$runtime_service_port/proxy$health_path" >"$health_file" 2>/dev/null \
&& node - "$health_file" <<'NODE'
const fs = require('node:fs');
const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
process.exit(body?.ok === true ? 0 : 1);
NODE
then
health_status=200
else
health_status=503
fi
rm -f "$health_file"
else
health_status=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 8 "$health_url" 2>/dev/null || true)
fi
fi
export UNIDESK_PAC_DIAG_IMAGE_REPOSITORY="$image_repository"
export UNIDESK_PAC_DIAG_REGISTRY_PROBE_BASE="$registry_probe_base"
export UNIDESK_PAC_DIAG_SOURCE_COMMIT="$source_commit"
@@ -814,6 +864,8 @@ NODE
export UNIDESK_PAC_DIAG_SENTINEL_ID="$sentinel_id"
export UNIDESK_PAC_DIAG_GITOPS_BRANCH="$gitops_branch"
export UNIDESK_PAC_DIAG_GITOPS_MANIFEST_PATH="$gitops_manifest_path"
export UNIDESK_PAC_DIAG_HEALTH_URL="$health_url"
export UNIDESK_PAC_DIAG_HEALTH_STATUS="$health_status"
node - "$params_file" "$pipelines_file" "$artifact_file" "$argo_file" "$runtime_file" <<'NODE'
const fs = require('node:fs');
function parseLoose(path, fallback) {
@@ -836,12 +888,15 @@ const registryDigest = process.env.UNIDESK_PAC_DIAG_REGISTRY_DIGEST || null;
const expectedDigest = registryDigest || artifact.digest || null;
const runtimeImage = runtime.image || null;
const runtimeMatches = !expectedDigest || (typeof runtimeImage === 'string' && runtimeImage.includes(expectedDigest));
const healthUrl = process.env.UNIDESK_PAC_DIAG_HEALTH_URL || null;
const healthStatus = process.env.UNIDESK_PAC_DIAG_HEALTH_STATUS || null;
const healthReady = !healthUrl || healthStatus === '200';
const gitopsReady = Boolean(artifact.gitopsCommit || argo.revision);
let code = 'pac-diagnostics-not-applicable';
let phase = 'not-applicable';
let ok = true;
let hint = 'consumer has no sentinel image_repository diagnostic config';
if (params.image_repository) {
if (process.env.UNIDESK_PAC_DIAG_IMAGE_REPOSITORY) {
if (!sourceCommit) {
ok = false; code = 'sentinel-pac-source-unknown'; phase = 'source-unknown'; hint = 'latest PaC PipelineRun did not expose a source commit';
} else if (!registryPresent) {
@@ -852,6 +907,8 @@ if (params.image_repository) {
ok = false; code = 'sentinel-pac-argo-not-ready'; phase = 'gitops-ready-argo-pending'; hint = 'GitOps exists but Argo is not Synced/Healthy';
} else if (!runtimeMatches) {
ok = false; code = 'sentinel-pac-runtime-not-aligned'; phase = 'argo-ready-runtime-mismatch'; hint = 'runtime image does not match the registry digest observed for the source commit';
} else if (!healthReady) {
ok = false; code = 'pac-health-not-ready'; phase = 'runtime-ready-health-pending'; hint = 'runtime image is aligned but the configured health endpoint is not ready';
} else {
code = 'sentinel-pac-ready'; phase = 'ready'; hint = 'source, registry tag, GitOps, Argo and runtime are aligned';
}
@@ -877,6 +934,7 @@ process.stdout.write(JSON.stringify({
},
argo: { sync: argo.sync || null, health: argo.health || null, revision: argo.revision || null },
runtime: { image: runtimeImage, digest: runtime.digest || null, readyReplicas: runtime.readyReplicas ?? null, replicas: runtime.replicas ?? null },
health: { url: healthUrl, status: healthStatus, ready: healthReady },
pipelineRun: latest.name || null,
valuesPrinted: false,
}));
@@ -120,6 +120,14 @@ interface PacConsumer {
pipelineRunPrefix: string;
argoNamespace: string;
argoApplication: string;
argoBootstrap: {
project: string;
repoUrl: string;
targetRevision: string;
path: string;
destinationNamespace: string;
automated: boolean;
} | null;
repositoryRef: string;
closeoutGitOpsMirrorFlush: boolean;
}
@@ -315,6 +323,7 @@ function parseRepository(repository: Record<string, unknown>, path: string): Pac
function parseConsumer(consumer: Record<string, unknown>, path: string, defaultRepositoryRef: string): PacConsumer {
const id = typeof consumer.id === "string" && consumer.id.length > 0 ? consumer.id : `${y.stringField(consumer, "node", path)}-${y.stringField(consumer, "lane", path)}`;
const argoBootstrap = consumer.argoBootstrap === undefined ? null : y.objectField(consumer, "argoBootstrap", path);
return {
id,
node: y.stringField(consumer, "node", path),
@@ -324,6 +333,14 @@ function parseConsumer(consumer: Record<string, unknown>, path: string, defaultR
pipelineRunPrefix: y.stringField(consumer, "pipelineRunPrefix", path),
argoNamespace: y.kubernetesNameField(consumer, "argoNamespace", path),
argoApplication: y.kubernetesNameField(consumer, "argoApplication", path),
argoBootstrap: argoBootstrap === null ? null : {
project: y.kubernetesNameField(argoBootstrap, "project", `${path}.argoBootstrap`),
repoUrl: urlField(argoBootstrap, "repoUrl", `${path}.argoBootstrap`),
targetRevision: y.stringField(argoBootstrap, "targetRevision", `${path}.argoBootstrap`),
path: y.stringField(argoBootstrap, "path", `${path}.argoBootstrap`),
destinationNamespace: y.kubernetesNameField(argoBootstrap, "destinationNamespace", `${path}.argoBootstrap`),
automated: y.booleanField(argoBootstrap, "automated", `${path}.argoBootstrap`),
},
repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef,
closeoutGitOpsMirrorFlush: consumer.closeoutGitOpsMirrorFlush === undefined ? false : y.booleanField(consumer, "closeoutGitOpsMirrorFlush", path),
};
@@ -673,6 +690,7 @@ function remoteScript(action: "apply" | "status" | "history" | "webhook-test", p
UNIDESK_PAC_DISPLAY_TIME_ZONE: pac.display.timeZone,
UNIDESK_PAC_ARGO_NAMESPACE: consumer.argoNamespace,
UNIDESK_PAC_ARGO_APPLICATION: consumer.argoApplication,
UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64: Buffer.from(argoBootstrapManifest(consumer), "utf8").toString("base64"),
UNIDESK_PAC_PART_OF: pacPartOf(consumer.id),
UNIDESK_PAC_SPEC: kubernetesLabelValue(pac.metadata.relatedIssues.includes(1555) ? "GH-1552-GH-1555" : pac.metadata.spec),
};
@@ -698,11 +716,46 @@ function credentialPath(root: string, sourceRef: string): string {
}
function pacPartOf(consumerId: string): string {
if (consumerId === "unidesk-host") return "unidesk-host";
if (consumerId.startsWith("sentinel")) return "hwlab-web-probe-sentinel";
if (consumerId.startsWith("hwlab-")) return "hwlab";
return "agentrun";
}
function argoBootstrapManifest(consumer: PacConsumer): string {
const bootstrap = consumer.argoBootstrap;
if (bootstrap === null) return "";
const application = {
apiVersion: "argoproj.io/v1alpha1",
kind: "Application",
metadata: {
name: consumer.argoApplication,
namespace: consumer.argoNamespace,
labels: {
"app.kubernetes.io/managed-by": "unidesk",
"app.kubernetes.io/part-of": pacPartOf(consumer.id),
},
},
spec: {
project: bootstrap.project,
source: {
repoURL: bootstrap.repoUrl,
targetRevision: bootstrap.targetRevision,
path: bootstrap.path,
},
destination: {
server: "https://kubernetes.default.svc",
namespace: bootstrap.destinationNamespace,
},
syncPolicy: bootstrap.automated ? {
automated: { prune: true, selfHeal: true },
syncOptions: ["CreateNamespace=true"],
} : undefined,
},
};
return `${Bun.YAML.stringify(application).trim()}\n`;
}
function parseLinePair(path: string, usernameLine: number, passwordLine: number): { username: string; password: string } {
const lines = readFileSync(path, "utf8").split(/\r?\n/u);
const username = lines[usernameLine - 1]?.trim() ?? "";
+30 -5
View File
@@ -4,7 +4,14 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { type UniDeskConfig } from "./config";
import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
import { summarizeMicroserviceHealthResponse, summarizeMicroserviceObservation, summarizeMicroserviceProxyResponse } from "./microservices";
import {
parseMicroserviceProxyRequest,
rewriteTodoNoteMisleadingRouteNotFound,
runTodoNoteCheckPath,
summarizeMicroserviceHealthResponse,
summarizeMicroserviceObservation,
summarizeMicroserviceProxyResponse,
} from "./microservices";
import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf";
import {
buildWindowsPowerShellInvocation,
@@ -742,19 +749,35 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro
};
}
if (action === "proxy" && id !== undefined && path !== undefined && path.startsWith("/")) {
const request = parseMicroserviceProxyRequest(args);
if (request.checkPath) {
if (id !== "todo-note") throw new Error(`--check-path currently only supports service=todo-note; got ${id}`);
return { transport: "frontend", response: runTodoNoteCheckPath(request.method, path) };
}
const full = args.includes("--full");
const raw = args.includes("--raw");
const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000);
const maxResponseBytes = full ? Math.min(Math.max(maxBodyBytes, 120_000), 5_000_000) : Math.min(Math.max(maxBodyBytes * 3, 240_000), 1_500_000);
const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, undefined, 24_000, maxResponseBytes);
const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, {
method: request.method,
body: request.body === undefined ? undefined : JSON.stringify(request.body),
}, 30_000, maxResponseBytes);
const rewritten = rewriteTodoNoteMisleadingRouteNotFound(id, request.method, path, response);
return {
transport: "frontend",
response: summarizeMicroserviceProxyResponse(response, args),
response: summarizeMicroserviceProxyResponse(rewritten, args),
};
}
throw new Error("remote microservice command must be: microservice list | status <id> | health <id> | diagnostics <id> | tunnel-self-test <id> | proxy <id> <path>");
}
function remoteMicroserviceOk(result: unknown): boolean {
if (typeof result !== "object" || result === null || Array.isArray(result)) return true;
const response = (result as { response?: unknown }).response;
if (typeof response !== "object" || response === null || Array.isArray(response)) return true;
return (response as { ok?: unknown }).ok !== false;
}
function commandResultFromFrontendTask(command: string[], task: { status?: string; result?: Record<string, unknown> } | undefined) {
const result = task?.result ?? {};
const stdout = typeof result.stdout === "string" ? result.stdout : "";
@@ -1615,8 +1638,10 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
return 0;
}
if (top === "microservice") {
emitRemoteJson(name, await remoteMicroservice(session, args));
return 0;
const result = await remoteMicroservice(session, args);
const ok = remoteMicroserviceOk(result);
emitRemoteJson(name, result, ok);
return ok ? 0 : 1;
}
if (top === "artifact-registry") {
emitRemoteJson(name, await remoteArtifactRegistry(session, args));
+1 -1
View File
@@ -10,7 +10,7 @@ export interface TransSshBackendConfig {
sourceRef: string;
}
const defaultTransConfigPath = ".env/trans.ymal";
const defaultTransConfigPath = "/root/.unidesk/.env/trans.ymal";
export function readTransSshBackendConfig(env: NodeJS.ProcessEnv = process.env): TransSshBackendConfig | null {
const configPath = resolveTransConfigPath(env);