feat: migrate todo note to nc01 github storage
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 ".";
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user