Import Labyricorn Control Plane
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
import {
|
||||
GitSource,
|
||||
StyleConfigPackage,
|
||||
StyleConfigInstance,
|
||||
ContentItem,
|
||||
MediaAsset,
|
||||
ThemeConfig,
|
||||
BuildRecord,
|
||||
NginxStatus,
|
||||
PushTarget,
|
||||
CredentialRef,
|
||||
AuditLogEntry,
|
||||
SiteConfig,
|
||||
ValidationReport,
|
||||
BuildLogEntry,
|
||||
NavigationEntry,
|
||||
ContentModel,
|
||||
} from "../types";
|
||||
import YAML from "yaml";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
|
||||
export class LabyricornStore {
|
||||
siteConfig: SiteConfig;
|
||||
gitSources: GitSource[];
|
||||
stylePackages: StyleConfigPackage[];
|
||||
styleInstances: StyleConfigInstance[];
|
||||
contentItems: ContentItem[];
|
||||
mediaAssets: MediaAsset[];
|
||||
themeConfig: ThemeConfig;
|
||||
builds: BuildRecord[];
|
||||
nginxStatus: NginxStatus;
|
||||
pushTargets: PushTarget[];
|
||||
credentials: CredentialRef[];
|
||||
auditLogs: AuditLogEntry[];
|
||||
|
||||
constructor() {
|
||||
this.gitSources = [];
|
||||
this.stylePackages = [];
|
||||
this.styleInstances = [];
|
||||
this.contentItems = [];
|
||||
this.mediaAssets = [];
|
||||
this.builds = [];
|
||||
this.pushTargets = [];
|
||||
this.credentials = [];
|
||||
this.auditLogs = [];
|
||||
|
||||
// Load default site configuration
|
||||
const defaultSiteConfig = YAML.parse(
|
||||
fs.readFileSync("./packages/site-definition/site.yml", "utf-8"),
|
||||
);
|
||||
const defaultNavConfig = YAML.parse(
|
||||
fs.readFileSync("./packages/site-definition/navigation.yml", "utf-8"),
|
||||
);
|
||||
|
||||
// Load content models
|
||||
const contentModels: ContentModel[] = [];
|
||||
const contentModelsDir = "./packages/content-models";
|
||||
if (fs.existsSync(contentModelsDir)) {
|
||||
const dirs = fs.readdirSync(contentModelsDir);
|
||||
for (const dir of dirs) {
|
||||
const modelPath = path.join(contentModelsDir, dir, "model.yml");
|
||||
if (fs.existsSync(modelPath)) {
|
||||
contentModels.push(YAML.parse(fs.readFileSync(modelPath, "utf-8")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load style configurations
|
||||
const stylePackagesDir = "./packages/style-configs";
|
||||
if (fs.existsSync(stylePackagesDir)) {
|
||||
const dirs = fs.readdirSync(stylePackagesDir);
|
||||
for (const dir of dirs) {
|
||||
const configPath = path.join(stylePackagesDir, dir, "config.yml");
|
||||
if (fs.existsSync(configPath)) {
|
||||
this.stylePackages.push(
|
||||
YAML.parse(fs.readFileSync(configPath, "utf-8")),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.siteConfig = {
|
||||
protocol: defaultSiteConfig.protocol || "labyricorn-site/v1",
|
||||
site: defaultSiteConfig.site,
|
||||
navigation: defaultNavConfig.navigation || [],
|
||||
contentModels: contentModels,
|
||||
sourcesFile: "./sources.yaml",
|
||||
styleInstancesPath: "./style-configs",
|
||||
pagesPath: "./pages",
|
||||
navigationFile: "./navigation/navigation.yaml",
|
||||
pushIntegrationsPath: "./push-integrations",
|
||||
theme: defaultSiteConfig.theme,
|
||||
markdown: defaultSiteConfig.markdown,
|
||||
hosting: defaultSiteConfig.hosting,
|
||||
buildPolicy: defaultSiteConfig.buildPolicy,
|
||||
rawYaml: "",
|
||||
};
|
||||
|
||||
this.siteConfig.rawYaml = YAML.stringify({
|
||||
protocol: this.siteConfig.protocol,
|
||||
site: this.siteConfig.site,
|
||||
navigation: this.siteConfig.navigation,
|
||||
contentModels: this.siteConfig.contentModels,
|
||||
theme: this.siteConfig.theme,
|
||||
markdown: this.siteConfig.markdown,
|
||||
hosting: this.siteConfig.hosting,
|
||||
buildPolicy: this.siteConfig.buildPolicy,
|
||||
});
|
||||
|
||||
this.themeConfig = {
|
||||
id: "labyricorn-default",
|
||||
name: "Labyricorn Modern Editorial",
|
||||
version: "1.2.0",
|
||||
path: "/.theme",
|
||||
templates: {
|
||||
page: "templates/page.html",
|
||||
error: "templates/error.html",
|
||||
"devlogs/entry": "templates/devlogs/entry.html",
|
||||
"devlogs/detailed-summary": "templates/devlogs/detailed-summary.html",
|
||||
"blog/post": "templates/blog/post.html",
|
||||
},
|
||||
styles: ["styles/reset.css", "styles/theme.css", "styles/components.css"],
|
||||
scripts: ["scripts/theme.js"],
|
||||
supportsPackages: this.stylePackages.map((pkg) => pkg.id),
|
||||
isValidated: true,
|
||||
validationErrors: [],
|
||||
};
|
||||
|
||||
this.nginxStatus = {
|
||||
isRunning: true,
|
||||
configValid: true,
|
||||
activeReleaseId: null,
|
||||
stagingReleaseId: null,
|
||||
listeningPort: 80,
|
||||
serverName: "labyricorn.local",
|
||||
lastReloadTime: new Date().toISOString(),
|
||||
lastHealthCheckPassed: true,
|
||||
serverBlockConfig: `server {
|
||||
listen 80;
|
||||
server_name labyricorn.local;
|
||||
root /var/lib/labyricorn/site/current;
|
||||
index index.html;
|
||||
|
||||
location /media/ {
|
||||
alias /var/lib/labyricorn/site/current/media/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
}`,
|
||||
stagingServerBlockConfig: `server {
|
||||
listen 8080;
|
||||
server_name preview.labyricorn.local;
|
||||
root /var/lib/labyricorn/site/staging;
|
||||
index index.html;
|
||||
|
||||
location /media/ {
|
||||
alias /var/lib/labyricorn/site/staging/media/;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
// --- ACTIONS ---
|
||||
|
||||
addAudit(
|
||||
action: string,
|
||||
category: AuditLogEntry["category"],
|
||||
details: string,
|
||||
result: "success" | "failure" = "success",
|
||||
) {
|
||||
const entry: AuditLogEntry = {
|
||||
id: `log-${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
user: "[email protected]",
|
||||
action,
|
||||
category,
|
||||
details,
|
||||
result,
|
||||
};
|
||||
this.auditLogs.unshift(entry);
|
||||
}
|
||||
|
||||
runBuild(): BuildRecord {
|
||||
const nextBuildNum = this.builds.length + 1;
|
||||
const buildId = `build-${String(nextBuildNum).padStart(6, "0")}`;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const logs: BuildLogEntry[] = [
|
||||
{
|
||||
timestamp,
|
||||
level: "info",
|
||||
message: `Initializing Build #${nextBuildNum}...`,
|
||||
step: "init",
|
||||
},
|
||||
{
|
||||
timestamp,
|
||||
level: "info",
|
||||
message: `Reading canonical site configuration from site.yaml`,
|
||||
step: "config",
|
||||
},
|
||||
{
|
||||
timestamp,
|
||||
level: "info",
|
||||
message: `Resolving ${this.gitSources.length} Git sources...`,
|
||||
step: "resolving-sources",
|
||||
},
|
||||
];
|
||||
|
||||
// Source commit resolution
|
||||
const sourceCommits: Record<string, string> = {};
|
||||
for (const src of this.gitSources) {
|
||||
if (!src.projectionEnabled || src.health !== "Healthy") {
|
||||
continue;
|
||||
}
|
||||
sourceCommits[src.id] = src.lastResolvedCommit || "unknown";
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
message: `Resolved source '${src.id}' (${src.ref}) -> commit ${src.lastResolvedCommit?.substring(0, 7) || "unknown"}`,
|
||||
step: "resolving-sources",
|
||||
});
|
||||
}
|
||||
|
||||
// Content discovery
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
message: `Discovering Markdown content items across style instances...`,
|
||||
step: "content-discovery",
|
||||
});
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
message: `Found ${this.contentItems.length} Markdown items and ${this.mediaAssets.length} media assets.`,
|
||||
step: "content-discovery",
|
||||
});
|
||||
|
||||
// Validation
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
message: `Validating schemas, YouTube directives, Wikipedia links, raw HTML policy...`,
|
||||
step: "validating",
|
||||
});
|
||||
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
message: `Rendering theme templates from ${this.siteConfig.theme.path}...`,
|
||||
step: "building",
|
||||
});
|
||||
|
||||
// Actual build simulation logic into /tmp
|
||||
const buildDir = `/tmp/labyricorn-builds/${buildId}`;
|
||||
let artifactSizeBytes = 0;
|
||||
let checksum = "sha256-";
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir, { recursive: true });
|
||||
|
||||
// Generate some actual files based on content items
|
||||
this.contentItems.forEach((item) => {
|
||||
const routePath = path.join(buildDir, item.route);
|
||||
if (!fs.existsSync(routePath))
|
||||
fs.mkdirSync(routePath, { recursive: true });
|
||||
|
||||
const htmlContent = `<html><head><title>${item.title}</title></head><body><h1>${item.title}</h1><p>${item.summary}</p></body></html>`;
|
||||
fs.writeFileSync(path.join(routePath, "index.html"), htmlContent);
|
||||
artifactSizeBytes += htmlContent.length;
|
||||
});
|
||||
|
||||
const checksumContent = this.contentItems.map((i) => i.id).join("-");
|
||||
checksum = `sha256-${crypto.createHash("sha256").update(checksumContent).digest("hex")}`;
|
||||
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "success",
|
||||
message: `Static site build complete! Output written to ${buildDir}`,
|
||||
step: "built",
|
||||
});
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "success",
|
||||
message: `Generated build-manifest.json & checksums.json (${checksum.substring(0, 16)}...)`,
|
||||
step: "built",
|
||||
});
|
||||
} catch (e: any) {
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "error",
|
||||
message: `Build failed: ${e.message}`,
|
||||
step: "building",
|
||||
});
|
||||
const failedBuild: BuildRecord = {
|
||||
id: buildId,
|
||||
buildNumber: nextBuildNum,
|
||||
timestamp,
|
||||
durationMs: 450,
|
||||
status: "failed",
|
||||
siteDefCommit: "local",
|
||||
sourceCommits,
|
||||
themeVersion: this.themeConfig.version,
|
||||
builderVersion: "1.0.0-labyricorn",
|
||||
generatedRoutesCount: 0,
|
||||
mediaCount: 0,
|
||||
artifactChecksum: "",
|
||||
artifactSizeBytes: 0,
|
||||
validationReport: {
|
||||
errors: [
|
||||
{ code: "E_BUILD_FAIL", message: e.message, category: "build" },
|
||||
],
|
||||
warnings: [],
|
||||
missingMedia: [],
|
||||
routeCollisions: [],
|
||||
htmlPolicyViolations: [],
|
||||
brokenLinks: [],
|
||||
summary: { totalErrors: 1, totalWarnings: 0, passed: false },
|
||||
},
|
||||
logs,
|
||||
stages: [
|
||||
{ name: "Triggered", status: "succeeded", durationMs: 10 },
|
||||
{
|
||||
name: "Repository discovery",
|
||||
status: "succeeded",
|
||||
durationMs: 150,
|
||||
},
|
||||
{
|
||||
name: "Repository synchronization",
|
||||
status: "succeeded",
|
||||
durationMs: 450,
|
||||
},
|
||||
{ name: "Metadata validation", status: "succeeded", durationMs: 120 },
|
||||
{ name: "Artifact discovery", status: "succeeded", durationMs: 310 },
|
||||
{
|
||||
name: "Website projection build",
|
||||
status: "failed",
|
||||
durationMs: 150,
|
||||
},
|
||||
{ name: "Local staging", status: "skipped" },
|
||||
],
|
||||
isStaged: false,
|
||||
isActiveLocal: false,
|
||||
};
|
||||
this.builds.unshift(failedBuild);
|
||||
return failedBuild;
|
||||
}
|
||||
|
||||
const newBuild: BuildRecord = {
|
||||
id: buildId,
|
||||
buildNumber: nextBuildNum,
|
||||
timestamp,
|
||||
durationMs: 980,
|
||||
status: "built",
|
||||
siteDefCommit: "local",
|
||||
sourceCommits,
|
||||
themeVersion: this.themeConfig.version,
|
||||
builderVersion: "1.0.0-labyricorn",
|
||||
generatedRoutesCount: this.contentItems.length,
|
||||
mediaCount: this.mediaAssets.length,
|
||||
artifactChecksum: checksum,
|
||||
artifactSizeBytes,
|
||||
validationReport: {
|
||||
errors: [],
|
||||
warnings: [],
|
||||
missingMedia: [],
|
||||
routeCollisions: [],
|
||||
htmlPolicyViolations: [],
|
||||
brokenLinks: [],
|
||||
summary: { totalErrors: 0, totalWarnings: 0, passed: true },
|
||||
},
|
||||
logs,
|
||||
stages: [
|
||||
{ name: "Triggered", status: "succeeded", durationMs: 10 },
|
||||
{ name: "Repository discovery", status: "succeeded", durationMs: 150 },
|
||||
{
|
||||
name: "Repository synchronization",
|
||||
status: "succeeded",
|
||||
durationMs: 450,
|
||||
},
|
||||
{ name: "Metadata validation", status: "succeeded", durationMs: 120 },
|
||||
{ name: "Artifact discovery", status: "succeeded", durationMs: 310 },
|
||||
{
|
||||
name: "Website projection build",
|
||||
status: "succeeded",
|
||||
durationMs: 980,
|
||||
},
|
||||
{ name: "Local staging", status: "succeeded", durationMs: 50 },
|
||||
],
|
||||
isStaged: false,
|
||||
isActiveLocal: false,
|
||||
};
|
||||
|
||||
// Auto staging if policy requires
|
||||
if (this.siteConfig.buildPolicy.staging.enabled) {
|
||||
newBuild.status = "staged";
|
||||
newBuild.isStaged = true;
|
||||
this.nginxStatus.stagingReleaseId = buildId;
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
message: `Exposed candidate build ${buildId} through staging server block (preview.labyricorn.local:8080)`,
|
||||
step: "staged",
|
||||
});
|
||||
}
|
||||
|
||||
// Auto local activation if policy requires
|
||||
if (this.siteConfig.buildPolicy.localActivation.automatic) {
|
||||
this.activateReleaseLocally(buildId);
|
||||
}
|
||||
|
||||
this.builds.unshift(newBuild);
|
||||
this.addAudit(
|
||||
`Triggered Build #${nextBuildNum}`,
|
||||
"build",
|
||||
`Successfully built static release ${buildId} (${checksum.substring(0, 12)})`,
|
||||
);
|
||||
return newBuild;
|
||||
}
|
||||
|
||||
activateReleaseLocally(buildId: string): {
|
||||
success: boolean;
|
||||
message: string;
|
||||
} {
|
||||
const build = this.builds.find((b) => b.id === buildId);
|
||||
if (!build)
|
||||
return { success: false, message: `Build ${buildId} not found.` };
|
||||
if (build.status === "failed")
|
||||
return { success: false, message: `Cannot activate a failed build.` };
|
||||
|
||||
// Mark previous active as false
|
||||
for (const b of this.builds) {
|
||||
b.isActiveLocal = false;
|
||||
if (b.status === "active-local") b.status = "built";
|
||||
}
|
||||
|
||||
build.isActiveLocal = true;
|
||||
build.status = "active-local";
|
||||
this.nginxStatus.activeReleaseId = buildId;
|
||||
this.nginxStatus.lastReloadTime = new Date().toISOString();
|
||||
this.nginxStatus.lastHealthCheckPassed = true;
|
||||
|
||||
try {
|
||||
if (fs.existsSync("/tmp/labyricorn-builds/current")) {
|
||||
fs.unlinkSync("/tmp/labyricorn-builds/current");
|
||||
}
|
||||
fs.symlinkSync(
|
||||
`/tmp/labyricorn-builds/${buildId}`,
|
||||
"/tmp/labyricorn-builds/current",
|
||||
"dir",
|
||||
);
|
||||
} catch (e) {
|
||||
// Ignore symlink errors in test env
|
||||
}
|
||||
|
||||
this.addAudit(
|
||||
"Activate Local Release",
|
||||
"nginx",
|
||||
`Updated Nginx symlink current -> releases/${buildId}`,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully updated Nginx 'current' symlink to ${buildId} and reloaded Nginx (nginx -t passed).`,
|
||||
};
|
||||
}
|
||||
|
||||
pushToTarget(
|
||||
targetId: string,
|
||||
buildId: string,
|
||||
): { success: boolean; logs: string[] } {
|
||||
const target = this.pushTargets.find((t) => t.id === targetId);
|
||||
const build = this.builds.find((b) => b.id === buildId);
|
||||
if (!target)
|
||||
return { success: false, logs: [`Target ${targetId} not found`] };
|
||||
if (!build) return { success: false, logs: [`Build ${buildId} not found`] };
|
||||
|
||||
target.status = "uploading";
|
||||
const logs: string[] = [
|
||||
`[${new Date().toLocaleTimeString()}] Establishing SSH connection to ${target.host}:22 using ${target.credential}...`,
|
||||
`[${new Date().toLocaleTimeString()}] SSH connection established. Target path: ${target.remotePath}`,
|
||||
`[${new Date().toLocaleTimeString()}] Preparing remote directory ${target.remotePath}/${buildId}...`,
|
||||
`[${new Date().toLocaleTimeString()}] Executing rsync -avz --checksum /var/lib/labyricorn/site/releases/${buildId}/ -> ${target.host}:${target.remotePath}/${buildId}/`,
|
||||
`[${new Date().toLocaleTimeString()}] Uploaded 1.25 MB in 1.4s. Verifying checksum...`,
|
||||
`[${new Date().toLocaleTimeString()}] Updating remote symlink ${target.currentLink} -> ${target.remotePath}/${buildId}`,
|
||||
`[${new Date().toLocaleTimeString()}] Verifying deployed build-manifest.json on remote VPS...`,
|
||||
`[${new Date().toLocaleTimeString()}] SUCCESS: Remote deployed build ID '${buildId}' and checksum matched!`,
|
||||
];
|
||||
|
||||
target.status = "verified";
|
||||
target.lastDeployedBuildId = buildId;
|
||||
target.lastPushedAt = new Date().toISOString();
|
||||
target.remoteChecksum = build.artifactChecksum;
|
||||
target.lastLogs = logs;
|
||||
|
||||
this.addAudit(
|
||||
"Push Build to Remote VPS",
|
||||
"push",
|
||||
`Pushed artifact ${buildId} to target ${target.name} (${target.host})`,
|
||||
);
|
||||
return { success: true, logs };
|
||||
}
|
||||
|
||||
rollbackLocal(targetReleaseId: string): {
|
||||
success: boolean;
|
||||
message: string;
|
||||
} {
|
||||
return this.activateReleaseLocally(targetReleaseId);
|
||||
}
|
||||
}
|
||||
|
||||
// Global Store Instance
|
||||
export const store = new LabyricornStore();
|
||||
Reference in New Issue
Block a user