clear old assets, new ci/cd flow
This commit is contained in:
334
scripts/generate-tokens.mjs
Normal file
334
scripts/generate-tokens.mjs
Normal file
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* generate-tokens.mjs
|
||||
*
|
||||
* Reads design-tokens/*.json and generates platform-specific code:
|
||||
* - web/src/theme/tokens.ts
|
||||
* - iOS/Kordant/Theme/GeneratedTokens.swift
|
||||
* - android/app/src/main/res/values/generated_tokens.xml
|
||||
*
|
||||
* Usage: node scripts/generate-tokens.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, "..");
|
||||
const tokensDir = join(root, "design-tokens");
|
||||
|
||||
function load(name) {
|
||||
return JSON.parse(readFileSync(join(tokensDir, `${name}.json`), "utf-8"));
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function toCamel(key) {
|
||||
return key.replace(/([A-Z])/g, (_, c) => `_${c}`).toLowerCase();
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = hex.replace("#", "");
|
||||
return {
|
||||
r: parseInt(h.substring(0, 2), 16),
|
||||
g: parseInt(h.substring(2, 4), 16),
|
||||
b: parseInt(h.substring(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Web (TypeScript) ──────────────────────────────────────────────────────
|
||||
|
||||
function generateWebTokens(colors, typography, spacing, shadows, radius) {
|
||||
const lines = [
|
||||
"// Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY",
|
||||
"// Run: node scripts/generate-tokens.mjs",
|
||||
"",
|
||||
];
|
||||
|
||||
// Colors
|
||||
lines.push("export const tokenColors = {");
|
||||
|
||||
// Brand
|
||||
lines.push(" brand: {");
|
||||
for (const [key, val] of Object.entries(colors.brand)) {
|
||||
lines.push(` ${toCamel(key)}: "${val.value}",`);
|
||||
}
|
||||
lines.push(" },");
|
||||
|
||||
// Semantic
|
||||
lines.push(" semantic: {");
|
||||
for (const [key, val] of Object.entries(colors.semantic)) {
|
||||
if (typeof val === "string") {
|
||||
lines.push(` ${toCamel(key)}: "${val}",`);
|
||||
} else if (val.light !== undefined) {
|
||||
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
|
||||
}
|
||||
}
|
||||
lines.push(" },");
|
||||
|
||||
// Background
|
||||
lines.push(" background: {");
|
||||
for (const [key, val] of Object.entries(colors.background)) {
|
||||
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
|
||||
}
|
||||
lines.push(" },");
|
||||
|
||||
// Text
|
||||
lines.push(" text: {");
|
||||
for (const [key, val] of Object.entries(colors.text)) {
|
||||
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
|
||||
}
|
||||
lines.push(" },");
|
||||
|
||||
// Border
|
||||
lines.push(" border: {");
|
||||
for (const [key, val] of Object.entries(colors.border)) {
|
||||
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
|
||||
}
|
||||
lines.push(" },");
|
||||
|
||||
lines.push("};");
|
||||
|
||||
// Typography
|
||||
lines.push("");
|
||||
lines.push("export const tokenTypography = {");
|
||||
lines.push(` fontFamily: "${typography.fontFamily.value}",`);
|
||||
lines.push(` fallback: "${typography.fontFamily.fallback}",`);
|
||||
lines.push(" scale: {");
|
||||
for (const [key, val] of Object.entries(typography.scale)) {
|
||||
lines.push(` ${toCamel(key)}: { size: "${val.size}", lineHeight: "${val.lineHeight}" },`);
|
||||
}
|
||||
lines.push(" },");
|
||||
lines.push(" weights: {");
|
||||
for (const [key, val] of Object.entries(typography.weights)) {
|
||||
lines.push(` ${key}: ${val.value},`);
|
||||
}
|
||||
lines.push(" },");
|
||||
lines.push("};");
|
||||
|
||||
// Spacing
|
||||
lines.push("");
|
||||
lines.push("export const tokenSpacing = {");
|
||||
for (const [key, val] of Object.entries(spacing.scale)) {
|
||||
lines.push(` ${key}: "${val.value}",`);
|
||||
}
|
||||
lines.push("};");
|
||||
|
||||
// Shadows
|
||||
lines.push("");
|
||||
lines.push("export const tokenShadows = {");
|
||||
for (const [key, val] of Object.entries(shadows.scale)) {
|
||||
const shadow = `${val.x}px ${val.y}px ${val.blur}px ${val.spread}px ${val.color}`;
|
||||
lines.push(` ${key}: "${shadow}",`);
|
||||
}
|
||||
lines.push("};");
|
||||
|
||||
// Radius
|
||||
lines.push("");
|
||||
lines.push("export const tokenRadius = {");
|
||||
for (const [key, val] of Object.entries(radius.scale)) {
|
||||
lines.push(` ${key}: "${val.value}",`);
|
||||
}
|
||||
lines.push("};");
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ─── iOS (Swift) ────────────────────────────────────────────────────────────
|
||||
|
||||
function generateSwiftTokens(colors, typography, spacing) {
|
||||
const lines = [
|
||||
"// Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY",
|
||||
"// Run: node scripts/generate-tokens.mjs",
|
||||
"",
|
||||
"import SwiftUI",
|
||||
"",
|
||||
];
|
||||
|
||||
// Color extension
|
||||
lines.push("extension Color {");
|
||||
lines.push(" // MARK: - Brand");
|
||||
for (const [key, val] of Object.entries(colors.brand)) {
|
||||
const { r, g, b } = hexToRgb(val.value);
|
||||
const swiftKey = key.charAt(0).toLowerCase() + key.slice(1);
|
||||
lines.push(
|
||||
` static let ${swiftKey} = Color(red: ${r} / 255, green: ${g} / 255, blue: ${b} / 255)`
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(" // MARK: - Semantic");
|
||||
for (const [key, val] of Object.entries(colors.semantic)) {
|
||||
if (val.value) {
|
||||
const { r, g, b } = hexToRgb(val.value);
|
||||
const swiftKey = key.charAt(0).toLowerCase() + key.slice(1);
|
||||
lines.push(
|
||||
` static let ${swiftKey} = Color(red: ${r} / 255, green: ${g} / 255, blue: ${b} / 255)`
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("}");
|
||||
|
||||
// AdaptiveColor helper
|
||||
lines.push("");
|
||||
lines.push("extension UIColor {");
|
||||
lines.push(" convenience init(hex: String) {");
|
||||
lines.push(
|
||||
' var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)'
|
||||
);
|
||||
lines.push(
|
||||
' hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")'
|
||||
);
|
||||
lines.push(" var rgb: UInt64 = 0");
|
||||
lines.push(" Scanner(string: hexSanitized).scanHexInt64(&rgb)");
|
||||
lines.push(" let r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0");
|
||||
lines.push(" let g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0");
|
||||
lines.push(" let b = CGFloat(rgb & 0x0000FF) / 255.0");
|
||||
lines.push(" self.init(red: r, green: g, blue: b, alpha: 1.0)");
|
||||
lines.push(" }");
|
||||
lines.push("}");
|
||||
|
||||
// Spacing
|
||||
lines.push("");
|
||||
lines.push("enum DesignTokens {");
|
||||
lines.push(" enum Spacing {");
|
||||
for (const [key, val] of Object.entries(spacing.scale)) {
|
||||
if (key === "0") continue; // skip numeric key — invalid in Swift
|
||||
const px = parseInt(val.value);
|
||||
lines.push(` static let ${key}: CGFloat = ${px}`);
|
||||
}
|
||||
lines.push(" }");
|
||||
lines.push("}");
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ─── Android (XML) ──────────────────────────────────────────────────────────
|
||||
|
||||
function generateAndroidTokens(colors, typography, spacing, shadows, radius) {
|
||||
const lines = [
|
||||
'<?xml version="1.0" encoding="utf-8"?>',
|
||||
"<!-- Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY -->",
|
||||
"<!-- Run: node scripts/generate-tokens.mjs -->",
|
||||
"<resources>",
|
||||
];
|
||||
|
||||
// Brand colors
|
||||
lines.push(" <!-- Brand -->");
|
||||
for (const [key, val] of Object.entries(colors.brand)) {
|
||||
const name = "brand_" + toCamel(key);
|
||||
lines.push(` <color name="${name}">${val.value}</color>`);
|
||||
}
|
||||
|
||||
// Semantic colors
|
||||
lines.push(" <!-- Semantic -->");
|
||||
for (const [key, val] of Object.entries(colors.semantic)) {
|
||||
if (typeof val === "string") {
|
||||
const name = "sem_" + toCamel(key);
|
||||
lines.push(` <color name="${name}">${val}</color>`);
|
||||
}
|
||||
}
|
||||
|
||||
// Light theme colors
|
||||
lines.push(" <!-- Light theme -->");
|
||||
lines.push(
|
||||
` <color name="bg_light">${colors.background.bg.light}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="bg_secondary_light">${colors.background.bgSecondary.light}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="text_primary_light">${colors.text.textPrimary.light}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="text_secondary_light">${colors.text.textSecondary.light}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="border_light">${colors.border.border.light}</color>`
|
||||
);
|
||||
|
||||
// Dark theme colors
|
||||
lines.push(" <!-- Dark theme -->");
|
||||
lines.push(
|
||||
` <color name="bg_dark">${colors.background.bg.dark}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="bg_secondary_dark">${colors.background.bgSecondary.dark}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="text_primary_dark">${colors.text.textPrimary.dark}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="text_secondary_dark">${colors.text.textSecondary.dark}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="border_dark">${colors.border.border.dark}</color>`
|
||||
);
|
||||
|
||||
// Spacing dimensions
|
||||
lines.push(" <!-- Spacing -->");
|
||||
for (const [key, val] of Object.entries(spacing.scale)) {
|
||||
const dp = val.value.replace("px", "dp");
|
||||
lines.push(` <dimen name="spacing_${key}">${dp}</dimen>`);
|
||||
}
|
||||
|
||||
// Border radius
|
||||
lines.push(" <!-- Corner radius -->");
|
||||
for (const [key, val] of Object.entries(radius.scale)) {
|
||||
const name = key === "full" ? "corner_full" : `corner_${key}`;
|
||||
const dp = val.value.replace("px", "dp");
|
||||
lines.push(` <dimen name="${name}">${dp}</dimen>`);
|
||||
}
|
||||
|
||||
// Font sizes
|
||||
lines.push(" <!-- Typography -->");
|
||||
for (const [key, val] of Object.entries(typography.scale)) {
|
||||
lines.push(` <dimen name="font_${toCamel(key)}">${val.size.replace("px", "sp")}</dimen>`);
|
||||
lines.push(` <dimen name="font_${toCamel(key)}_lh">${val.lineHeight.replace("px", "sp")}</dimen>`);
|
||||
}
|
||||
|
||||
lines.push("</resources>");
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const colors = load("colors");
|
||||
const typography = load("typography");
|
||||
const spacing = load("spacing");
|
||||
const shadows = load("shadows");
|
||||
const radius = load("radius");
|
||||
|
||||
// Web
|
||||
const webPath = join(root, "web", "src", "theme", "tokens.ts");
|
||||
mkdirSync(dirname(webPath), { recursive: true });
|
||||
writeFileSync(webPath, generateWebTokens(colors, typography, spacing, shadows, radius));
|
||||
console.log(`✅ ${webPath}`);
|
||||
|
||||
// iOS
|
||||
const iosPath = join(root, "iOS", "Kordant", "Theme", "GeneratedTokens.swift");
|
||||
mkdirSync(dirname(iosPath), { recursive: true });
|
||||
writeFileSync(iosPath, generateSwiftTokens(colors, typography, spacing));
|
||||
console.log(`✅ ${iosPath}`);
|
||||
|
||||
// Android
|
||||
const androidPath = join(
|
||||
root,
|
||||
"android",
|
||||
"app",
|
||||
"src",
|
||||
"main",
|
||||
"res",
|
||||
"values",
|
||||
"generated_tokens.xml"
|
||||
);
|
||||
mkdirSync(dirname(androidPath), { recursive: true });
|
||||
writeFileSync(androidPath, generateAndroidTokens(colors, typography, spacing, shadows, radius));
|
||||
console.log(`✅ ${androidPath}`);
|
||||
|
||||
console.log("🎨 Token generation complete.");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Trend, Rate } from 'k6/metrics';
|
||||
|
||||
export const errorRate = new Rate('error_rate');
|
||||
|
||||
export function getBaseUrl() {
|
||||
return __ENV.BASE_URL || 'http://localhost:3000';
|
||||
}
|
||||
|
||||
export function getTargetRps() {
|
||||
return parseInt(__ENV.TARGET_RPS || '500', 10);
|
||||
}
|
||||
|
||||
export function getDuration() {
|
||||
return __ENV.DURATION || '300s';
|
||||
}
|
||||
|
||||
export function defaultThresholds(p99ms) {
|
||||
return {
|
||||
thresholds: {
|
||||
http_req_duration: [`p(99)<${p99ms}`],
|
||||
error_rate: ['rate<0.01'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function checkResponse(res, expectedStatus = 200) {
|
||||
const pass = check(res, {
|
||||
'status is expected': (r) => r.status === expectedStatus,
|
||||
'response time OK': (r) => r.timings.duration < 5000,
|
||||
});
|
||||
errorRate.add(!pass);
|
||||
return pass;
|
||||
}
|
||||
|
||||
export function randomString(length = 10) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const autoscaleMetric = new Trend('autoscale_vu_count');
|
||||
|
||||
export function recordAutoscaleMetric(vuCount) {
|
||||
autoscaleMetric.add(vuCount);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Combined load test runner for all Kordant services
|
||||
# Usage: ./run-all.sh [service]
|
||||
# service: all (default), api, darkwatch, spamshield, voiceprint
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPORT_DIR="${SCRIPT_DIR}/reports"
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
THRESHOLD_FILE="${REPORT_DIR}/threshold-results.json"
|
||||
SERVICE="${1:-all}"
|
||||
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
BASE_URL="${LOAD_TEST_BASE_URL:-http://localhost:3000}"
|
||||
TARGET_RPS="${TARGET_RPS:-500}"
|
||||
DURATION="${DURATION:-300s}"
|
||||
API_TOKEN="${API_TOKEN:-}"
|
||||
if [[ -z "$API_TOKEN" ]]; then
|
||||
echo "⚠️ API_TOKEN not set (load tests will run without auth)"
|
||||
fi
|
||||
|
||||
echo "=== Kordant Combined Load Test ==="
|
||||
echo "Timestamp: $TIMESTAMP"
|
||||
echo "Base URL: $BASE_URL"
|
||||
echo "Target RPS: $TARGET_RPS"
|
||||
echo "Duration: $DURATION"
|
||||
echo "Service: $SERVICE"
|
||||
echo ""
|
||||
|
||||
K6_OPTS="--summary-export ${REPORT_DIR}/summary-${TIMESTAMP}.json"
|
||||
|
||||
if [[ -n "${K6_CLOUD_TOKEN:-}" ]]; then
|
||||
K6_OPTS="$K6_OPTS --out cloud"
|
||||
echo "k6 cloud output: enabled"
|
||||
fi
|
||||
|
||||
declare -A EXIT_CODES
|
||||
ALL_PASSED=true
|
||||
SERVICE_ENV="BASE_URL=$BASE_URL TARGET_RPS=$TARGET_RPS DURATION=$DURATION API_TOKEN=$API_TOKEN"
|
||||
|
||||
run_service_test() {
|
||||
local name=$1
|
||||
local script=$2
|
||||
local summary_file="${REPORT_DIR}/${name}-summary-${TIMESTAMP}.json"
|
||||
|
||||
echo ""
|
||||
echo "=== Running $name Load Test ==="
|
||||
|
||||
local opts="--summary-export $summary_file"
|
||||
if [[ -n "${K6_CLOUD_TOKEN:-}" ]]; then
|
||||
opts="$opts --out cloud"
|
||||
fi
|
||||
|
||||
set +e
|
||||
eval "$SERVICE_ENV" k6 run $opts "$script"
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
EXIT_CODES[$name]=$EXIT_CODE
|
||||
if [[ $EXIT_CODE -ne 0 ]]; then
|
||||
ALL_PASSED=false
|
||||
echo "❌ $name load test FAILED (exit code: $EXIT_CODE)"
|
||||
else
|
||||
echo "✅ $name load test PASSED"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$SERVICE" == "all" || "$SERVICE" == "api" ]]; then
|
||||
run_service_test "api" "${SCRIPT_DIR}/services/api.js"
|
||||
fi
|
||||
|
||||
if [[ "$SERVICE" == "all" || "$SERVICE" == "darkwatch" ]]; then
|
||||
run_service_test "darkwatch" "${SCRIPT_DIR}/services/darkwatch.js"
|
||||
fi
|
||||
|
||||
if [[ "$SERVICE" == "all" || "$SERVICE" == "spamshield" ]]; then
|
||||
run_service_test "spamshield" "${SCRIPT_DIR}/services/spamshield.js"
|
||||
fi
|
||||
|
||||
if [[ "$SERVICE" == "all" || "$SERVICE" == "voiceprint" ]]; then
|
||||
run_service_test "voiceprint" "${SCRIPT_DIR}/services/voiceprint.js"
|
||||
fi
|
||||
|
||||
# Aggregate threshold results from all service summaries
|
||||
echo ""
|
||||
echo "=== Load Test Results ==="
|
||||
|
||||
# Build threshold-results.json from k6 summary exports
|
||||
jq -n --arg timestamp "$TIMESTAMP" --arg base_url "$BASE_URL" --arg target_rps "$TARGET_RPS" \
|
||||
'{timestamp: $timestamp, base_url: $base_url, target_rps: $target_rps, services: {}}' \
|
||||
> "$THRESHOLD_FILE"
|
||||
|
||||
for name in "${!EXIT_CODES[@]}"; do
|
||||
summary_file="${REPORT_DIR}/${name}-summary-${TIMESTAMP}.json"
|
||||
if [[ -f "$summary_file" ]]; then
|
||||
jq --arg name "$name" --argjson exit_code "${EXIT_CODES[$name]}" \
|
||||
'.services[$name] = {
|
||||
exitCode: $exit_code,
|
||||
passed: ($exit_code == 0),
|
||||
metrics: (input | .metrics // {})
|
||||
}' \
|
||||
"$THRESHOLD_FILE" "$summary_file" \
|
||||
> "${THRESHOLD_FILE}.tmp" && mv "${THRESHOLD_FILE}.tmp" "$THRESHOLD_FILE"
|
||||
else
|
||||
jq --arg name "$name" --argjson exit_code "${EXIT_CODES[$name]}" \
|
||||
'.services[$name] = {exitCode: $exit_code, passed: ($exit_code == 0)}' \
|
||||
"$THRESHOLD_FILE" > "${THRESHOLD_FILE}.tmp" && mv "${THRESHOLD_FILE}.tmp" "$THRESHOLD_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Threshold results saved to: $THRESHOLD_FILE"
|
||||
|
||||
for service in "${!EXIT_CODES[@]}"; do
|
||||
status="pass"
|
||||
[[ ${EXIT_CODES[$service]} -ne 0 ]] && status="fail"
|
||||
echo "$service: $status"
|
||||
done
|
||||
|
||||
echo ""
|
||||
if $ALL_PASSED; then
|
||||
echo "✅ All load tests passed"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ Some load tests failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,62 +0,0 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group } from 'k6';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
import { getBaseUrl, getTargetRps, getDuration, defaultThresholds, checkResponse, randomString } from '../lib/common.js';
|
||||
|
||||
const notificationLatency = new Trend('notification_p99');
|
||||
const correlationLatency = new Trend('correlation_p99');
|
||||
|
||||
const TARGET_RPS = getTargetRps();
|
||||
const DURATION = getDuration();
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
sustained_load: {
|
||||
executor: 'constant-arrival-rate',
|
||||
duration: DURATION,
|
||||
rate: TARGET_RPS,
|
||||
preAllocatedVUs: 20,
|
||||
maxVUs: 100,
|
||||
startTime: '0s',
|
||||
exec: 'default',
|
||||
tags: { scenario: 'sustained_load' },
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...defaultThresholds(250).thresholds,
|
||||
notification_p99: ['p(99)<500'],
|
||||
correlation_p99: ['p(99)<300'],
|
||||
},
|
||||
};
|
||||
|
||||
const BASE_URL = getBaseUrl();
|
||||
const AUTH_TOKEN = __ENV.API_TOKEN || '';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
};
|
||||
|
||||
export default function () {
|
||||
group('API Health', function () {
|
||||
const res = http.get(`${BASE_URL}/health`, { headers });
|
||||
checkResponse(res, 200);
|
||||
});
|
||||
|
||||
group('Notifications', function () {
|
||||
const payload = JSON.stringify({
|
||||
userId: `user-${randomString()}`,
|
||||
channel: 'email',
|
||||
message: 'Load test notification',
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/notifications`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
notificationLatency.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Correlation', function () {
|
||||
const res = http.get(`${BASE_URL}/correlation/events?limit=10`, { headers });
|
||||
checkResponse(res, 200);
|
||||
correlationLatency.add(res.timings.duration);
|
||||
});
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group } from 'k6';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
import { getBaseUrl, getTargetRps, getDuration, defaultThresholds, checkResponse, randomString } from '../lib/common.js';
|
||||
|
||||
const scanLatency = new Trend('scan_p99');
|
||||
const watchlistLatency = new Trend('watchlist_p99');
|
||||
const alertLatency = new Trend('alert_p99');
|
||||
|
||||
const TARGET_RPS = getTargetRps();
|
||||
const DURATION = getDuration();
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
sustained_load: {
|
||||
executor: 'constant-arrival-rate',
|
||||
duration: DURATION,
|
||||
rate: TARGET_RPS,
|
||||
preAllocatedVUs: 20,
|
||||
maxVUs: 100,
|
||||
startTime: '0s',
|
||||
exec: 'default',
|
||||
tags: { scenario: 'sustained_load' },
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...defaultThresholds(200).thresholds,
|
||||
scan_p99: ['p(99)<300'],
|
||||
watchlist_p99: ['p(99)<200'],
|
||||
alert_p99: ['p(99)<250'],
|
||||
},
|
||||
};
|
||||
|
||||
const BASE_URL = getBaseUrl();
|
||||
const AUTH_TOKEN = __ENV.API_TOKEN || '';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
};
|
||||
|
||||
export default function () {
|
||||
group('Darkwatch Scan', function () {
|
||||
const payload = JSON.stringify({
|
||||
type: 'email',
|
||||
value: `loadtest-${randomString()}@example.com`,
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/darkwatch/scan`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
scanLatency.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Watchlist', function () {
|
||||
const res = http.get(`${BASE_URL}/watchlist?page=1&limit=20`, { headers });
|
||||
checkResponse(res, 200);
|
||||
watchlistLatency.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Alerts', function () {
|
||||
const res = http.get(`${BASE_URL}/alerts?status=open&limit=10`, { headers });
|
||||
checkResponse(res, 200);
|
||||
alertLatency.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Exposure Check', function () {
|
||||
const res = http.get(`${BASE_URL}/exposure/summary`, { headers });
|
||||
checkResponse(res, 200);
|
||||
});
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group } from 'k6';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
import { getBaseUrl, defaultThresholds, checkResponse, randomString } from '../lib/common.js';
|
||||
|
||||
const smsClassifyP99 = new Trend('sms_classify_p99');
|
||||
const numberReputationP99 = new Trend('number_reputation_p99');
|
||||
const callAnalyzeP99 = new Trend('call_analyze_p99');
|
||||
|
||||
const TARGET_RPS = getTargetRps();
|
||||
const DURATION = getDuration();
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
sustained_load: {
|
||||
executor: 'constant-arrival-rate',
|
||||
duration: DURATION,
|
||||
rate: TARGET_RPS,
|
||||
preAllocatedVUs: 20,
|
||||
maxVUs: 100,
|
||||
startTime: '0s',
|
||||
exec: 'default',
|
||||
tags: { scenario: 'sustained_load' },
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...defaultThresholds(400).thresholds,
|
||||
sms_classify_p99: ['p(99)<150'],
|
||||
number_reputation_p99: ['p(99)<300'],
|
||||
call_analyze_p99: ['p(99)<400'],
|
||||
},
|
||||
};
|
||||
|
||||
const BASE_URL = getBaseUrl();
|
||||
const AUTH_TOKEN = __ENV.API_TOKEN || '';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
};
|
||||
|
||||
export default function () {
|
||||
group('SMS Text Classification', function () {
|
||||
const payload = JSON.stringify({
|
||||
text: `Is this message a spam attempt? ${randomString(16)}`,
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/spamshield/sms/classify`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
smsClassifyP99.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Number Reputation Check', function () {
|
||||
const payload = JSON.stringify({
|
||||
phoneNumber: `+1555${String(Math.floor(1000000 + Math.random() * 9000000))}`,
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/spamshield/number/reputation`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
numberReputationP99.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Call Analysis', function () {
|
||||
const payload = JSON.stringify({
|
||||
phoneNumber: `+1555${String(Math.floor(1000000 + Math.random() * 9000000))}`,
|
||||
callTime: new Date().toISOString(),
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/spamshield/call/analyze`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
callAnalyzeP99.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Spam Feedback', function () {
|
||||
const payload = JSON.stringify({
|
||||
phoneNumber: `+1555${String(Math.floor(1000000 + Math.random() * 9000000))}`,
|
||||
isSpam: false,
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/spamshield/feedback`, payload, { headers });
|
||||
check(res, { 'feedback status is 201': (r) => r.status === 201 });
|
||||
});
|
||||
|
||||
group('Spam History', function () {
|
||||
const res = http.get(`${BASE_URL}/spamshield/history?limit=10`, { headers });
|
||||
checkResponse(res, 200);
|
||||
});
|
||||
|
||||
group('Spam Statistics', function () {
|
||||
const res = http.get(`${BASE_URL}/spamshield/statistics`, { headers });
|
||||
checkResponse(res, 200);
|
||||
});
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group } from 'k6';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
import { getBaseUrl, getTargetRps, getDuration, defaultThresholds, checkResponse, randomString } from '../lib/common.js';
|
||||
|
||||
const enrollmentLatency = new Trend('enrollment_p99');
|
||||
const verificationLatency = new Trend('verification_p99');
|
||||
const modelLatency = new Trend('model_retrieval_p99');
|
||||
|
||||
const TARGET_RPS = getTargetRps();
|
||||
const DURATION = getDuration();
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
sustained_load: {
|
||||
executor: 'constant-arrival-rate',
|
||||
duration: DURATION,
|
||||
rate: TARGET_RPS,
|
||||
preAllocatedVUs: 20,
|
||||
maxVUs: 100,
|
||||
startTime: '0s',
|
||||
exec: 'default',
|
||||
tags: { scenario: 'sustained_load' },
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...defaultThresholds(250).thresholds,
|
||||
enrollment_p99: ['p(99)<500'],
|
||||
verification_p99: ['p(99)<250'],
|
||||
model_retrieval_p99: ['p(99)<100'],
|
||||
},
|
||||
};
|
||||
|
||||
const BASE_URL = getBaseUrl();
|
||||
const AUTH_TOKEN = __ENV.API_TOKEN || '';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
};
|
||||
|
||||
export default function () {
|
||||
group('Voiceprint Enrollment', function () {
|
||||
const payload = JSON.stringify({
|
||||
userId: `loadtest-${randomString()}`,
|
||||
audioSample: 'base64-encoded-audio-data-placeholder',
|
||||
sampleRate: 16000,
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/voiceprint/enroll`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
enrollmentLatency.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Voiceprint Verification', function () {
|
||||
const payload = JSON.stringify({
|
||||
userId: `loadtest-${randomString()}`,
|
||||
audioSample: 'base64-encoded-audio-data-placeholder',
|
||||
sampleRate: 16000,
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/voiceprint/verify`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
verificationLatency.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Model Retrieval', function () {
|
||||
const res = http.get(`${BASE_URL}/voiceprint/model/${randomString()}`, { headers });
|
||||
checkResponse(res, 200);
|
||||
modelLatency.add(res.timings.duration);
|
||||
});
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# GA4 Setup Script for Kordant
|
||||
# Two modes:
|
||||
# 1. MANUAL: Step-by-step guide for GA web console (no credentials needed)
|
||||
# 2. AUTOMATED: Creates property + stream via Admin API (requires GCP service account)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup-ga4.sh # Print manual instructions
|
||||
# ./scripts/setup-ga4.sh --auto # Automated setup (needs GOOGLE_APPLICATION_CREDENTIALS)
|
||||
# ./scripts/setup-ga4.sh --env-only # Just print what to put in .env
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
show_manual_guide() {
|
||||
cat <<'GUIDE'
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ Kordant — Manual GA4 Setup Guide ║
|
||||
║ ~5 minutes in Google Analytics web console ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
STEP 1 — Create GA4 Property
|
||||
1. Go to https://analytics.google.com/
|
||||
2. Admin → Create Property → "Kordant"
|
||||
3. Set reporting time zone, currency
|
||||
4. Click "Create"
|
||||
|
||||
STEP 2 — Configure Data Stream
|
||||
1. In the new property: Admin → Data Streams → Add Stream → Web
|
||||
2. Website URL: https://kordant.ai
|
||||
3. Stream name: "Kordant Landing Page"
|
||||
4. Click "Create stream"
|
||||
5. Copy the Measurement ID (format: G-XXXXXXXXXX)
|
||||
|
||||
STEP 3 — Create API Secret
|
||||
1. In the data stream details: Measurement Protocol API secrets → Create
|
||||
2. Nickname: "Kordant Backend"
|
||||
3. Copy the API Secret
|
||||
|
||||
STEP 4 — Set Up Conversion Events
|
||||
1. In GA4: Admin → Conversions → New conversion event
|
||||
2. Create: "waitlist_signup"
|
||||
3. Create: "page_view" (auto-tracked by default)
|
||||
4. Optionally: "conversion" (for tracked conversions)
|
||||
|
||||
STEP 5 — Configure Environment
|
||||
Add to .env (or .env.prod for production):
|
||||
GA4_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||
GA4_API_SECRET=<api-secret-from-step-3>
|
||||
|
||||
STEP 6 — Verify
|
||||
curl -X POST "https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=<secret>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"client_id":"test-001","events":[{"name":"page_view"}]}'
|
||||
GUIDE
|
||||
}
|
||||
|
||||
setup_automated() {
|
||||
if [ -z "${GOOGLE_APPLICATION_CREDENTIALS:-}" ]; then
|
||||
echo "ERROR: GOOGLE_APPLICATION_CREDENTIALS not set"
|
||||
echo "Set it to the path of your GCP service account JSON key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node &>/dev/null; then
|
||||
echo "ERROR: node is required for automated setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- Automated GA4 Setup ---"
|
||||
echo "Using service account: $GOOGLE_APPLICATION_CREDENTIALS"
|
||||
|
||||
# Generate a setup script that uses the Google Admin API
|
||||
cat > /tmp/setup-ga4-auto.mjs << 'SCRIPT'
|
||||
import { google } from 'googleapis';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
|
||||
async function main() {
|
||||
const creds = JSON.parse(readFileSync(process.env.GOOGLE_APPLICATION_CREDENTIALS, 'utf-8'));
|
||||
const auth = new google.auth.GoogleAuth({
|
||||
credentials: creds,
|
||||
scopes: ['https://www.googleapis.com/auth/analytics.edit'],
|
||||
});
|
||||
|
||||
const analyticsAdmin = google.analyticsadmin({ version: 'v1beta', auth });
|
||||
|
||||
// Step 1: Create GA4 property
|
||||
console.log('Creating GA4 property...');
|
||||
const property = await analyticsAdmin.properties.create({
|
||||
requestBody: {
|
||||
displayName: 'Kordant',
|
||||
industryCategory: 'TECHNOLOGY',
|
||||
timeZone: 'America/New_York',
|
||||
currencyCode: 'USD',
|
||||
parent: `accounts/${creds.account_id || '103950747'}`, // Replace with actual account ID
|
||||
},
|
||||
});
|
||||
console.log(`Property created: ${property.data.name}`);
|
||||
|
||||
// Step 2: Create web data stream
|
||||
console.log('Creating web data stream...');
|
||||
const stream = await analyticsAdmin.properties.dataStreams.create({
|
||||
parent: property.data.name,
|
||||
requestBody: {
|
||||
type: 'WEB_DATA_STREAM',
|
||||
displayName: 'Kordant Landing Page',
|
||||
webStreamData: {
|
||||
defaultUri: 'https://kordant.ai',
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(`Data stream created: ${stream.data.name}`);
|
||||
console.log(`Measurement ID: ${stream.data.webStreamData.measurementId}`);
|
||||
|
||||
// Step 3: Create conversion events
|
||||
console.log('Creating conversion events...');
|
||||
for (const event of ['waitlist_signup']) {
|
||||
try {
|
||||
await analyticsAdmin.properties.conversionEvents.create({
|
||||
parent: property.data.name,
|
||||
requestBody: { eventName: event },
|
||||
});
|
||||
console.log(`Conversion event created: ${event}`);
|
||||
} catch (e) {
|
||||
console.log(`Conversion event ${event} may already exist: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Output results
|
||||
const output = {
|
||||
propertyId: property.data.name.replace('properties/', ''),
|
||||
measurementId: stream.data.webStreamData.measurementId,
|
||||
streamId: stream.data.name,
|
||||
streamName: stream.data.displayName,
|
||||
};
|
||||
writeFileSync('/tmp/ga4-setup-output.json', JSON.stringify(output, null, 2));
|
||||
console.log('\nResults saved to /tmp/ga4-setup-output.json');
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
SCRIPT
|
||||
|
||||
echo ""
|
||||
echo "To run the automated setup:"
|
||||
echo " 1. Update the account_id in the script above"
|
||||
echo " 2. cd $PROJECT_DIR && node /tmp/setup-ga4-auto.mjs"
|
||||
echo ""
|
||||
echo "NOTE: You need to provide the Google Analytics account ID."
|
||||
echo "Find it at: https://analytics.google.com/ → Admin → Account Settings"
|
||||
}
|
||||
|
||||
show_env_only() {
|
||||
cat <<'ENV'
|
||||
Required .env additions for Kordant analytics:
|
||||
|
||||
GA4_MEASUREMENT_ID=G-XXXXXXXXXX # From GA4 data stream
|
||||
GA4_API_SECRET= # From GA4 Measurement Protocol API secrets
|
||||
MIXPANEL_TOKEN= # Mixpanel project token
|
||||
MIXPANEL_API_SECRET= # Mixpanel project API secret
|
||||
ENV
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--auto)
|
||||
setup_automated
|
||||
;;
|
||||
--env-only)
|
||||
show_env_only
|
||||
;;
|
||||
*)
|
||||
show_manual_guide
|
||||
;;
|
||||
esac
|
||||
252
scripts/setup-pan.sh
Executable file
252
scripts/setup-pan.sh
Executable file
@@ -0,0 +1,252 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Kordant Scheduler — Pan Server Setup ─────────────────────
|
||||
# Usage:
|
||||
# bash scripts/setup-pan.sh # interactively
|
||||
# bash scripts/setup-pan.sh user@pan # interactively, remote
|
||||
# bash scripts/setup-pan.sh -u <url> -d <url> ...
|
||||
#
|
||||
# Flags:
|
||||
# -u, --gitea-url URL Gitea clone URL (skip prompt)
|
||||
# -d, --db-url URL Turso database URL (skip prompt)
|
||||
# -t, --db-token TOKEN Turso auth token (skip prompt)
|
||||
# -k, --hooks-dir DIR Gitea hooks directory (skip prompt; empty=skip hook)
|
||||
# --host HOST SSH host (default: pan)
|
||||
# -y, --non-interactive Skip all prompts (requires -u, -d, -t)
|
||||
# -h, --help Show this message
|
||||
|
||||
# ─── Defaults ───────────────────────────────────────────────────
|
||||
|
||||
PAN_HOST=""
|
||||
GITEA_URL=""
|
||||
DB_URL=""
|
||||
DB_TOKEN=""
|
||||
HOOKS_DIR=""
|
||||
NON_INTERACTIVE=false
|
||||
REPO_DIR="/opt/kordant"
|
||||
|
||||
# ─── Help ───────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
sed -n 's/^# //p; s/^#$//p' "$0"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ─── Parse flags ────────────────────────────────────────────────
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-u|--gitea-url) GITEA_URL="$2"; shift 2 ;;
|
||||
-d|--db-url) DB_URL="$2"; shift 2 ;;
|
||||
-t|--db-token) DB_TOKEN="$2"; shift 2 ;;
|
||||
-k|--hooks-dir) HOOKS_DIR="$2"; shift 2 ;;
|
||||
--host) PAN_HOST="$2"; shift 2 ;;
|
||||
-y|--non-interactive) NON_INTERACTIVE=true; shift ;;
|
||||
-h|--help) usage ;;
|
||||
-*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
;;
|
||||
*) PAN_HOST="${PAN_HOST:-$1}"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PAN_HOST="${PAN_HOST:-pan}"
|
||||
|
||||
# ─── Remote detection ───────────────────────────────────────────
|
||||
|
||||
if [ "$(hostname)" != "pan" ] && [ "$(hostname -s 2>/dev/null)" != "pan" ]; then
|
||||
if [ -n "${SSH_CONNECTION:-}" ]; then
|
||||
echo "Already connected to pan via SSH."
|
||||
else
|
||||
echo "Not on pan. Connecting to $PAN_HOST via SSH..."
|
||||
# Rebuild the flag string to pass through
|
||||
FLAGS=""
|
||||
[ -n "$GITEA_URL" ] && FLAGS="$FLAGS -u '$GITEA_URL'"
|
||||
[ -n "$DB_URL" ] && FLAGS="$FLAGS -d '$DB_URL'"
|
||||
[ -n "$DB_TOKEN" ] && FLAGS="$FLAGS -t '$DB_TOKEN'"
|
||||
[ -n "$HOOKS_DIR" ] && FLAGS="$FLAGS -k '$HOOKS_DIR'"
|
||||
$NON_INTERACTIVE && FLAGS="$FLAGS -y"
|
||||
scp "$0" "${PAN_HOST}:/tmp/kordant-setup.sh"
|
||||
ssh -t "$PAN_HOST" "sudo bash /tmp/kordant-setup.sh $FLAGS && rm /tmp/kordant-setup.sh"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== Kordant Scheduler Setup (running on pan) ==="
|
||||
|
||||
# ─── Step 1: Install prerequisites ─────────────────────────────
|
||||
|
||||
echo "--- Step 1: Checking prerequisites ---"
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
echo "Installing Docker..."
|
||||
curl -fsSL https://get.docker.com | bash
|
||||
systemctl enable --now docker
|
||||
else
|
||||
echo "Docker already installed."
|
||||
fi
|
||||
|
||||
if ! docker compose version &>/dev/null; then
|
||||
echo "Installing Docker Compose plugin..."
|
||||
apt-get update && apt-get install -y docker-compose-plugin
|
||||
else
|
||||
echo "Docker Compose plugin already installed."
|
||||
fi
|
||||
|
||||
if ! command -v git &>/dev/null; then
|
||||
echo "Installing git..."
|
||||
apt-get update && apt-get install -y git
|
||||
else
|
||||
echo "Git already installed."
|
||||
fi
|
||||
|
||||
# ─── Step 2: Clone or pull the repo ────────────────────────────
|
||||
|
||||
echo "--- Step 2: Setting up repo at $REPO_DIR ---"
|
||||
|
||||
if [ ! -d "$REPO_DIR/.git" ]; then
|
||||
if [ -z "$GITEA_URL" ]; then
|
||||
if $NON_INTERACTIVE; then
|
||||
echo "❌ --gitea-url is required in non-interactive mode"
|
||||
exit 1
|
||||
fi
|
||||
while [ -z "$GITEA_URL" ]; do
|
||||
read -rp "Gitea clone URL (e.g. http://localhost:3000/kordant/kordant.git): " GITEA_URL
|
||||
done
|
||||
fi
|
||||
echo "Cloning $GITEA_URL ..."
|
||||
git clone "$GITEA_URL" "$REPO_DIR"
|
||||
cd "$REPO_DIR"
|
||||
else
|
||||
echo "Repo exists. Pulling latest..."
|
||||
cd "$REPO_DIR"
|
||||
git pull
|
||||
fi
|
||||
|
||||
# ─── Step 3: Create .env with credentials ──────────────────────
|
||||
|
||||
echo "--- Step 3: Environment file ---"
|
||||
|
||||
if [ ! -f "$REPO_DIR/.env" ]; then
|
||||
if [ -z "$DB_URL" ]; then
|
||||
if $NON_INTERACTIVE; then
|
||||
echo "❌ --db-url is required in non-interactive mode"
|
||||
exit 1
|
||||
fi
|
||||
read -rp " DATABASE_URL (e.g. libsql://kordant.turso.io): " DB_URL
|
||||
fi
|
||||
if [ -z "$DB_TOKEN" ]; then
|
||||
if $NON_INTERACTIVE; then
|
||||
echo "❌ --db-token is required in non-interactive mode"
|
||||
exit 1
|
||||
fi
|
||||
read -rsp " DATABASE_AUTH_TOKEN: " DB_TOKEN
|
||||
echo ""
|
||||
fi
|
||||
|
||||
cat > "$REPO_DIR/.env" << ENVEOF
|
||||
# ─── Kordant Scheduler Environment ─────────────────────────────
|
||||
# Turso database
|
||||
DATABASE_URL="${DB_URL}"
|
||||
DATABASE_AUTH_TOKEN="${DB_TOKEN}"
|
||||
|
||||
# Job queue (local Redis container)
|
||||
REDIS_URL="redis://redis:6379"
|
||||
|
||||
# Node environment
|
||||
NODE_ENV=production
|
||||
JOB_WORKER=true
|
||||
JOB_PRIMARY=true
|
||||
ENVEOF
|
||||
chmod 600 "$REPO_DIR/.env"
|
||||
echo "✅ Created $REPO_DIR/.env"
|
||||
else
|
||||
echo "$REPO_DIR/.env already exists, keeping it."
|
||||
fi
|
||||
|
||||
# ─── Step 4: Gitea post-receive hook ────────────────────────────
|
||||
|
||||
echo "--- Step 4: Gitea post-receive hook ---"
|
||||
|
||||
if [ -z "$HOOKS_DIR" ] && ! $NON_INTERACTIVE; then
|
||||
read -rp "Gitea repo hooks directory (or leave blank to skip): " HOOKS_DIR
|
||||
fi
|
||||
|
||||
if [ -n "$HOOKS_DIR" ]; then
|
||||
if [ ! -d "$HOOKS_DIR" ]; then
|
||||
echo " Directory not found: $HOOKS_DIR"
|
||||
if $NON_INTERACTIVE; then
|
||||
echo "❌ hooks-dir does not exist"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
HOOK_FILE="$HOOKS_DIR/post-receive"
|
||||
cat > "$HOOK_FILE" << 'HOOKEOF'
|
||||
#!/bin/bash
|
||||
cd /opt/kordant
|
||||
git pull origin main
|
||||
docker compose -f scheduler/docker-compose.yml up -d --build
|
||||
HOOKEOF
|
||||
chmod +x "$HOOK_FILE"
|
||||
echo "✅ Post-receive hook installed at $HOOK_FILE"
|
||||
fi
|
||||
else
|
||||
echo "Skipping post-receive hook."
|
||||
fi
|
||||
|
||||
# ─── Step 5: Create systemd service ────────────────────────────
|
||||
|
||||
echo "--- Step 5: Systemd service ---"
|
||||
|
||||
SERVICE_NAME="kordant-scheduler"
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
|
||||
if [ ! -f "$SERVICE_FILE" ]; then
|
||||
echo "Creating $SERVICE_FILE..."
|
||||
cat > "$SERVICE_FILE" << SERVICEEOF
|
||||
[Unit]
|
||||
Description=Kordant Background Job Scheduler
|
||||
After=docker.service network-online.target
|
||||
Wants=docker.service network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=$REPO_DIR
|
||||
ExecStart=docker compose -f scheduler/docker-compose.yml up -d --build
|
||||
ExecStop=docker compose -f scheduler/docker-compose.yml down
|
||||
ExecReload=docker compose -f scheduler/docker-compose.yml pull && docker compose -f scheduler/docker-compose.yml up -d --build
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICEEOF
|
||||
systemctl daemon-reload
|
||||
else
|
||||
echo "$SERVICE_FILE already exists."
|
||||
fi
|
||||
|
||||
# ─── Step 6: Start / restart the scheduler ─────────────────────
|
||||
|
||||
echo "--- Step 6: Starting scheduler ---"
|
||||
|
||||
systemctl enable "$SERVICE_NAME" 2>/dev/null || true
|
||||
|
||||
cd "$REPO_DIR"
|
||||
docker compose -f scheduler/docker-compose.yml pull 2>/dev/null || true
|
||||
docker compose -f scheduler/docker-compose.yml up -d --build
|
||||
|
||||
echo ""
|
||||
echo "=== Scheduler status ==="
|
||||
sleep 3
|
||||
docker compose -f scheduler/docker-compose.yml ps
|
||||
|
||||
echo ""
|
||||
echo "=== Kordant scheduler setup complete ==="
|
||||
echo " Repo: $REPO_DIR"
|
||||
echo " Logs: journalctl -u kordant-scheduler -f"
|
||||
echo " Shell: cd $REPO_DIR && docker compose -f scheduler/docker-compose.yml logs -f"
|
||||
echo " .env: $REPO_DIR/.env"
|
||||
Reference in New Issue
Block a user