diff --git a/.env.example b/.env.example index 5a19eda..cd1bedf 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,5 @@ LUME_RUNNER_ENV_FILE=~/Library/Application Support/github-runner-fleet/lume/runn LUME_RUNNER_IPSW_PATH=~/Library/Application Support/github-runner-fleet/lume/cache/latest.ipsw COMPOSE_PROJECT_NAME=github-runner-fleet RUNNER_VERSION=2.333.0 +OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com:4318 +OTEL_EXPORTER_OTLP_HEADERS= diff --git a/README.md b/README.md index c0d8802..a3db592 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,25 @@ The Synology shell-only class supports shell jobs, JavaScript actions, composite - [docker/runner-entrypoint.sh](docker/runner-entrypoint.sh): ephemeral registration and cleanup flow - [src/cli.ts](src/cli.ts): config validation, compose rendering, and runner release helpers +## Runner Telemetry + +Each runner pool can opt into OpenTelemetry by adding a `telemetry` block. When enabled, rendered Compose services and Lume runner exports include standard `OTEL_*` variables for the runner process. The collector endpoint stays configurable through deployment env interpolation, so the same non-secret pool config can point to different remote targets per host or environment. + +```yaml +telemetry: + enabled: true + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT} + protocol: http/protobuf + headers: ${OTEL_EXPORTER_OTLP_HEADERS:-} + tracesExporter: otlp + metricsExporter: otlp + logsExporter: otlp + resourceAttributes: + service.namespace: github-runner-fleet +``` + +Supported options are `endpoint`, `protocol` (`grpc` or `http/protobuf`), `headers`, `serviceName`, `tracesExporter`, `metricsExporter`, `logsExporter`, and `resourceAttributes`. Keep auth-bearing headers in `.env` or the target host environment, not in committed config. If `telemetry.enabled` is `true`, validation requires `endpoint`. + ## Synology Quick Start 1. Copy `.env.example` to `.env` and set `GITHUB_PAT`. diff --git a/config/linux-docker-runners.yaml b/config/linux-docker-runners.yaml index 2af6740..403096a 100644 --- a/config/linux-docker-runners.yaml +++ b/config/linux-docker-runners.yaml @@ -15,6 +15,13 @@ pools: size: 2 architecture: amd64 runnerRoot: ${LINUX_DOCKER_RUNNER_BASE_DIR}/pools/linux-docker-private + # telemetry: + # enabled: true + # endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT} + # protocol: http/protobuf + # headers: ${OTEL_EXPORTER_OTLP_HEADERS:-} + # resourceAttributes: + # service.namespace: github-runner-fleet resources: cpus: "4" memory: 8g diff --git a/config/lume-runners.yaml b/config/lume-runners.yaml index 6c3f905..310727f 100644 --- a/config/lume-runners.yaml +++ b/config/lume-runners.yaml @@ -21,3 +21,10 @@ pool: guestRunnerRoot: /Users/lume/actions-runner guestWorkRoot: /Users/lume/actions-runner/_work runnerVersion: 2.333.0 + # telemetry: + # enabled: true + # endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT} + # protocol: http/protobuf + # headers: ${OTEL_EXPORTER_OTLP_HEADERS:-} + # resourceAttributes: + # service.namespace: github-runner-fleet diff --git a/config/pools.yaml b/config/pools.yaml index 10369c4..90e9854 100644 --- a/config/pools.yaml +++ b/config/pools.yaml @@ -20,6 +20,13 @@ pools: cooldownSeconds: 120 architecture: auto runnerRoot: ${SYNOLOGY_RUNNER_BASE_DIR}/pools/synology-private + # telemetry: + # enabled: true + # endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT} + # protocol: http/protobuf + # headers: ${OTEL_EXPORTER_OTLP_HEADERS:-} + # resourceAttributes: + # service.namespace: github-runner-fleet resources: memory: 2g - key: synology-public diff --git a/config/windows-runners.yaml b/config/windows-runners.yaml index d8517db..3667d5e 100644 --- a/config/windows-runners.yaml +++ b/config/windows-runners.yaml @@ -12,6 +12,13 @@ pools: sshPort: ${WINDOWS_DOCKER_PORT:-22} image: ghcr.io/example/github-runner-fleet:0.1.9-windows runnerRoot: ${WINDOWS_DOCKER_RUNNER_BASE_DIR}/pools/windows-private + # telemetry: + # enabled: true + # endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT} + # protocol: http/protobuf + # headers: ${OTEL_EXPORTER_OTLP_HEADERS:-} + # resourceAttributes: + # service.namespace: github-runner-fleet labels: - x64 resources: diff --git a/src/lib/compose.ts b/src/lib/compose.ts index c9d44fe..02e085f 100644 --- a/src/lib/compose.ts +++ b/src/lib/compose.ts @@ -1,6 +1,10 @@ import YAML from "yaml"; import type { DeploymentEnv } from "./env.js"; -import type { PoolConfig, ResolvedConfig } from "./config.js"; +import { + renderTelemetryEnvironment, + type PoolConfig, + type ResolvedConfig +} from "./config.js"; export function renderCompose( config: ResolvedConfig, @@ -60,7 +64,18 @@ function renderService(pool: PoolConfig, index: number): Record RUNNER_TOOL_CACHE: "/opt/hostedtoolcache", AGENT_TOOLSDIRECTORY: "/opt/hostedtoolcache", RUNNER_EPHEMERAL: "true", - RUNNER_DISABLE_UPDATE: "true" + RUNNER_DISABLE_UPDATE: "true", + ...renderTelemetryEnvironment(pool.telemetry, { + serviceName: "github-runner-fleet.synology", + resourceAttributes: { + "deployment.environment": pool.visibility, + "github.organization": pool.organization, + "runner.group": pool.runnerGroup, + "runner.name": buildRunnerName(pool, index), + "runner.pool": pool.key, + "runner.plane": "synology" + } + }) }; if (pool.repositoryAccess === "selected") { diff --git a/src/lib/config.ts b/src/lib/config.ts index 0db90dd..3a5559b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -4,6 +4,11 @@ import YAML from "yaml"; import { z } from "zod"; import type { DeploymentEnv } from "./env.js"; import type { RunnerArchitecture } from "./runner-version.js"; +import { + renderTelemetryEnvironment, + telemetrySchema, + type TelemetryConfig +} from "./telemetry.js"; export type RunnerVisibility = "private" | "public"; export type RepositoryAccess = "all" | "selected"; @@ -35,6 +40,7 @@ export interface PoolConfig { runnerRoot: string; resources: PoolResources; scaling?: PoolScaling; + telemetry?: TelemetryConfig; imageRef: string; } @@ -97,7 +103,8 @@ const poolSchema = z queueThreshold: z.number().int().min(1), cooldownSeconds: z.number().int().min(0) }) - .optional() + .optional(), + telemetry: telemetrySchema }) .superRefine((pool, ctx) => { if (pool.repositoryAccess === "selected" && pool.allowedRepositories.length === 0) { @@ -148,6 +155,7 @@ export function loadConfig( const seenKeys = new Set(); const pools = result.pools.map((pool) => { + const { telemetry, ...poolValues } = pool; if (seenKeys.has(pool.key)) { throw new Error(`duplicate pool key: ${pool.key}`); } @@ -171,7 +179,7 @@ export function loadConfig( } return { - ...pool, + ...poolValues, labels: uniqueLabels(pool.labels, pool.visibility), resources: { cpus: pool.resources.cpus, @@ -179,6 +187,7 @@ export function loadConfig( pidsLimit: pool.resources.pidsLimit }, scaling: pool.scaling, + ...(telemetry.enabled ? { telemetry } : {}), imageRef: `${result.image.repository}:${result.image.tag}` }; }); @@ -190,6 +199,8 @@ export function loadConfig( }; } +export { renderTelemetryEnvironment }; + function uniqueLabels( labels: string[], visibility: RunnerVisibility diff --git a/src/lib/linux-docker-compose.ts b/src/lib/linux-docker-compose.ts index 731ec2a..739dae1 100644 --- a/src/lib/linux-docker-compose.ts +++ b/src/lib/linux-docker-compose.ts @@ -4,6 +4,7 @@ import type { ResolvedLinuxDockerConfig } from "./linux-docker-config.js"; import type { DeploymentEnv } from "./env.js"; +import { renderTelemetryEnvironment } from "./telemetry.js"; export function renderLinuxDockerCompose( config: ResolvedLinuxDockerConfig, @@ -73,7 +74,18 @@ function renderService( RUNNER_EXEC_MODE_OVERRIDE: "root", RUNNER_EPHEMERAL: "true", RUNNER_DISABLE_UPDATE: "true", - DOCKER_HOST: "unix:///var/run/docker.sock" + DOCKER_HOST: "unix:///var/run/docker.sock", + ...renderTelemetryEnvironment(pool.telemetry, { + serviceName: "github-runner-fleet.linux-docker", + resourceAttributes: { + "deployment.environment": pool.visibility, + "github.organization": pool.organization, + "runner.group": pool.runnerGroup, + "runner.name": buildLinuxDockerServiceName(pool, index), + "runner.pool": pool.key, + "runner.plane": "linux-docker" + } + }) }; if (pool.repositoryAccess === "selected") { diff --git a/src/lib/linux-docker-config.ts b/src/lib/linux-docker-config.ts index a46e221..9c78c1f 100644 --- a/src/lib/linux-docker-config.ts +++ b/src/lib/linux-docker-config.ts @@ -8,6 +8,7 @@ import type { RunnerPlatform } from "./config.js"; import type { DeploymentEnv } from "./env.js"; +import { telemetrySchema, type TelemetryConfig } from "./telemetry.js"; export interface LinuxDockerPoolConfig { key: string; @@ -21,6 +22,7 @@ export interface LinuxDockerPoolConfig { architecture: RunnerPlatform; runnerRoot: string; resources: PoolResources; + telemetry?: TelemetryConfig; imageRef: string; } @@ -54,7 +56,8 @@ const poolSchema = z memory: z.string().min(1).optional(), pidsLimit: z.number().int().positive().optional() }) - .default({}) + .default({}), + telemetry: telemetrySchema }) .superRefine((pool, ctx) => { if (pool.repositoryAccess === "selected" && pool.allowedRepositories.length === 0) { @@ -97,6 +100,7 @@ export function loadLinuxDockerConfig( const seenKeys = new Set(); const pools = result.pools.map((pool) => { + const { telemetry, ...poolValues } = pool; if (seenKeys.has(pool.key)) { throw new Error(`duplicate linux-docker pool key: ${pool.key}`); } @@ -120,7 +124,7 @@ export function loadLinuxDockerConfig( } return { - ...pool, + ...poolValues, visibility: "private" as const, labels: uniqueLabels(pool.labels), resources: { @@ -128,6 +132,7 @@ export function loadLinuxDockerConfig( memory: pool.resources.memory, pidsLimit: pool.resources.pidsLimit }, + ...(telemetry.enabled ? { telemetry } : {}), imageRef: `${result.image.repository}:${result.image.tag}` }; }); diff --git a/src/lib/lume-config.ts b/src/lib/lume-config.ts index 93f2d88..939c3ab 100644 --- a/src/lib/lume-config.ts +++ b/src/lib/lume-config.ts @@ -3,6 +3,11 @@ import path from "node:path"; import YAML from "yaml"; import { z } from "zod"; import type { DeploymentEnv } from "./env.js"; +import { + renderTelemetryEnvironment, + telemetrySchema, + type TelemetryConfig +} from "./telemetry.js"; export interface LumePoolConfig { key: string; @@ -23,6 +28,7 @@ export interface LumePoolConfig { guestRunnerRoot: string; guestWorkRoot: string; runnerVersion: string; + telemetry?: TelemetryConfig; } export interface LumeSlotManifest { @@ -73,7 +79,8 @@ const poolSchema = z.object({ guestPassword: z.string().min(1).default("lume"), guestRunnerRoot: z.string().min(1).default("/Users/lume/actions-runner"), guestWorkRoot: z.string().min(1).default("/Users/lume/actions-runner/_work"), - runnerVersion: z.string().min(1).optional() + runnerVersion: z.string().min(1).optional(), + telemetry: telemetrySchema }); const configSchema = z.object({ @@ -99,11 +106,13 @@ export function loadLumeConfig( throw new Error("LUME_RUNNER_ENV_FILE must resolve to an absolute path"); } - const normalizedLabels = normalizeLabels(result.pool.labels); + const { telemetry, ...poolValues } = result.pool; + const normalizedLabels = normalizeLabels(poolValues.labels); const pool: LumePoolConfig = { - ...result.pool, + ...poolValues, labels: normalizedLabels, - runnerVersion: result.pool.runnerVersion ?? env.runnerVersion + runnerVersion: poolValues.runnerVersion ?? env.runnerVersion, + ...(telemetry.enabled ? { telemetry } : {}) }; const host = { @@ -161,6 +170,17 @@ export function renderLumeShellExports( LUME_SLOT_KEY: slot.slotKey, LUME_VM_NAME: slot.vmName, RUNNER_NAME: slot.runnerName, + ...renderTelemetryEnvironment(config.pool.telemetry, { + serviceName: "github-runner-fleet.lume", + resourceAttributes: { + "github.organization": config.pool.organization, + "runner.group": config.pool.runnerGroup, + "runner.name": slot.runnerName, + "runner.pool": config.pool.key, + "runner.plane": "lume", + "runner.slot": slot.slotKey + } + }), LUME_SLOT_DIR: slot.hostDir, LUME_SLOT_WORKER_PID_FILE: slot.workerPidFile, LUME_SLOT_VM_PID_FILE: slot.vmPidFile, diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts new file mode 100644 index 0000000..6c3ced3 --- /dev/null +++ b/src/lib/telemetry.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +export interface TelemetryConfig { + enabled: boolean; + endpoint: string; + protocol?: "grpc" | "http/protobuf"; + serviceName?: string; + headers?: string; + tracesExporter: "otlp" | "none"; + metricsExporter: "otlp" | "none"; + logsExporter: "otlp" | "none"; + resourceAttributes: Record; +} + +export const telemetrySchema = z + .object({ + enabled: z.boolean().default(false), + endpoint: z.string().url().optional(), + protocol: z.enum(["grpc", "http/protobuf"]).optional(), + serviceName: z.string().min(1).optional(), + headers: z.string().min(1).optional(), + tracesExporter: z.enum(["otlp", "none"]).default("otlp"), + metricsExporter: z.enum(["otlp", "none"]).default("otlp"), + logsExporter: z.enum(["otlp", "none"]).default("otlp"), + resourceAttributes: z.record(z.string(), z.string()).default({}) + }) + .default({ + enabled: false, + tracesExporter: "otlp", + metricsExporter: "otlp", + logsExporter: "otlp", + resourceAttributes: {} + }) + .superRefine((telemetry, ctx) => { + if (telemetry.enabled && !telemetry.endpoint) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "endpoint is required when telemetry.enabled is true", + path: ["endpoint"] + }); + } + }) + .transform((telemetry): TelemetryConfig => ({ + enabled: telemetry.enabled, + endpoint: telemetry.endpoint ?? "", + protocol: telemetry.protocol, + serviceName: telemetry.serviceName, + headers: telemetry.headers, + tracesExporter: telemetry.tracesExporter, + metricsExporter: telemetry.metricsExporter, + logsExporter: telemetry.logsExporter, + resourceAttributes: telemetry.resourceAttributes + })); + +export function renderTelemetryEnvironment( + telemetry: TelemetryConfig | undefined, + defaults: { + serviceName: string; + resourceAttributes: Record; + } +): Record { + if (!telemetry?.enabled) { + return {}; + } + + const resourceAttributes = { + ...defaults.resourceAttributes, + ...telemetry.resourceAttributes + }; + const environment: Record = { + OTEL_EXPORTER_OTLP_ENDPOINT: telemetry.endpoint, + OTEL_SERVICE_NAME: telemetry.serviceName ?? defaults.serviceName, + OTEL_TRACES_EXPORTER: telemetry.tracesExporter, + OTEL_METRICS_EXPORTER: telemetry.metricsExporter, + OTEL_LOGS_EXPORTER: telemetry.logsExporter, + OTEL_RESOURCE_ATTRIBUTES: Object.entries(resourceAttributes) + .map(([key, value]) => `${key}=${value}`) + .join(",") + }; + + if (telemetry.protocol) { + environment.OTEL_EXPORTER_OTLP_PROTOCOL = telemetry.protocol; + } + + if (telemetry.headers) { + environment.OTEL_EXPORTER_OTLP_HEADERS = telemetry.headers; + } + + return environment; +} diff --git a/src/lib/windows-compose.ts b/src/lib/windows-compose.ts index 9fedecf..31bc334 100644 --- a/src/lib/windows-compose.ts +++ b/src/lib/windows-compose.ts @@ -5,6 +5,7 @@ import type { ResolvedWindowsDockerConfig, WindowsDockerPoolConfig } from "./windows-config.js"; +import { renderTelemetryEnvironment } from "./telemetry.js"; export function renderWindowsDockerCompose( config: ResolvedWindowsDockerConfig, @@ -72,7 +73,18 @@ function renderService( AGENT_TOOLSDIRECTORY: runnerToolCache, RUNNER_EPHEMERAL: "true", RUNNER_DISABLE_UPDATE: "true", - DOCKER_HOST: "npipe:////./pipe/docker_engine" + DOCKER_HOST: "npipe:////./pipe/docker_engine", + ...renderTelemetryEnvironment(pool.telemetry, { + serviceName: "github-runner-fleet.windows-docker", + resourceAttributes: { + "deployment.environment": pool.visibility, + "github.organization": pool.organization, + "runner.group": pool.runnerGroup, + "runner.name": serviceName, + "runner.pool": pool.key, + "runner.plane": "windows-docker" + } + }) }; if (pool.repositoryAccess === "selected") { diff --git a/src/lib/windows-config.ts b/src/lib/windows-config.ts index 1f9437a..16660f9 100644 --- a/src/lib/windows-config.ts +++ b/src/lib/windows-config.ts @@ -4,6 +4,7 @@ import YAML from "yaml"; import { z } from "zod"; import type { PoolResources, RepositoryAccess } from "./config.js"; import type { DeploymentEnv } from "./env.js"; +import { telemetrySchema, type TelemetryConfig } from "./telemetry.js"; export interface WindowsDockerPoolConfig { key: string; @@ -19,6 +20,7 @@ export interface WindowsDockerPoolConfig { sshPort: string; runnerRoot: string; resources: PoolResources; + telemetry?: TelemetryConfig; imageRef: string; } @@ -61,7 +63,8 @@ const poolSchema = z memory: z.string().min(1).optional(), pidsLimit: z.number().int().positive().optional() }) - .default({}) + .default({}), + telemetry: telemetrySchema }) .superRefine((pool, ctx) => { if (!pool.key && !pool.name) { @@ -124,6 +127,7 @@ export function loadWindowsDockerConfig( const seenKeys = new Set(); const pools = result.pools.map((pool) => { + const { telemetry } = pool; const key = pool.key ?? pool.name!; if (seenKeys.has(key)) { throw new Error(`duplicate windows-docker pool key: ${key}`); @@ -180,6 +184,7 @@ export function loadWindowsDockerConfig( memory: pool.resources.memory, pidsLimit: pool.resources.pidsLimit }, + ...(telemetry.enabled ? { telemetry } : {}), imageRef }; }); diff --git a/test/compose.test.ts b/test/compose.test.ts index 9d7ae2c..cd9ca13 100644 --- a/test/compose.test.ts +++ b/test/compose.test.ts @@ -55,6 +55,40 @@ describe("renderCompose", () => { expect(publicService).not.toHaveProperty("cpus"); expect(publicService).not.toHaveProperty("pids_limit"); }); + + test("renders OTEL environment when telemetry is enabled for a pool", () => { + const config = configFixture(); + config.pools[0].telemetry = { + enabled: true, + endpoint: "https://otel.example.com:4318", + protocol: "http/protobuf", + serviceName: "synology-private-runners", + tracesExporter: "none", + metricsExporter: "otlp", + logsExporter: "otlp", + resourceAttributes: { + "service.namespace": "runner-fleet" + } + }; + + const compose = renderCompose(config, envFixture()); + const payload = YAML.parse(compose.split("\n").slice(2).join("\n")) as { + services: Record>; + }; + + expect( + payload.services["synology-private-runner-01"].environment + ).toMatchObject({ + OTEL_EXPORTER_OTLP_ENDPOINT: "https://otel.example.com:4318", + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_SERVICE_NAME: "synology-private-runners", + OTEL_TRACES_EXPORTER: "none", + OTEL_METRICS_EXPORTER: "otlp", + OTEL_LOGS_EXPORTER: "otlp", + OTEL_RESOURCE_ATTRIBUTES: + "deployment.environment=private,github.organization=example,runner.group=synology-private,runner.name=synology-private-runner-01,runner.pool=synology-private,runner.plane=synology,service.namespace=runner-fleet" + }); + }); }); function configFixture(): ResolvedConfig { diff --git a/test/config.test.ts b/test/config.test.ts index 280d4bc..4afbe43 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -91,6 +91,93 @@ pools: }); }); + test("loads opt-in OTEL telemetry settings", () => { + const directory = createTempDir(); + const configPath = path.join(directory, "pools.yaml"); + + fs.writeFileSync( + configPath, + `version: 1 +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.5 +pools: + - key: synology-private + visibility: private + organization: example + runnerGroup: synology-private + repositoryAccess: all + labels: [] + size: 1 + architecture: arm64 + runnerRoot: /volume1/docker/github-runner-fleet/pools/synology-private + telemetry: + enabled: true + endpoint: \${OTEL_EXPORTER_OTLP_ENDPOINT} + protocol: http/protobuf + serviceName: synology-private-runners + headers: Authorization=Bearer \${OTEL_EXPORTER_OTLP_TOKEN} + metricsExporter: otlp + tracesExporter: none + logsExporter: otlp + resourceAttributes: + service.namespace: runner-fleet +`, + "utf8" + ); + + const config = loadConfig( + configPath, + deploymentEnv({ + OTEL_EXPORTER_OTLP_ENDPOINT: "https://otel.example.com/v1", + OTEL_EXPORTER_OTLP_TOKEN: "test-token" + }) + ); + + expect(config.pools[0].telemetry).toMatchObject({ + enabled: true, + endpoint: "https://otel.example.com/v1", + protocol: "http/protobuf", + serviceName: "synology-private-runners", + headers: "Authorization=Bearer test-token", + tracesExporter: "none", + resourceAttributes: { + "service.namespace": "runner-fleet" + } + }); + }); + + test("requires telemetry endpoint when telemetry is enabled", () => { + const directory = createTempDir(); + const configPath = path.join(directory, "pools.yaml"); + + fs.writeFileSync( + configPath, + `version: 1 +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.5 +pools: + - key: synology-private + visibility: private + organization: example + runnerGroup: synology-private + repositoryAccess: all + labels: [] + size: 1 + architecture: arm64 + runnerRoot: /volume1/docker/github-runner-fleet/pools/synology-private + telemetry: + enabled: true +`, + "utf8" + ); + + expect(() => loadConfig(configPath, deploymentEnv())).toThrow( + /endpoint is required when telemetry\.enabled is true/ + ); + }); + test("rejects autoscaling bounds with min greater than max", () => { const directory = createTempDir(); const configPath = path.join(directory, "pools.yaml"); @@ -361,7 +448,7 @@ pools: }); }); -function deploymentEnv(): DeploymentEnv { +function deploymentEnv(raw: Record = {}): DeploymentEnv { return { githubApiUrl: "https://api.github.com", synologyRunnerBaseDir: "/volume1/docker/github-runner-fleet", @@ -397,7 +484,8 @@ function deploymentEnv(): DeploymentEnv { runnerVersion: "2.327.1", raw: { SYNOLOGY_RUNNER_BASE_DIR: "/volume1/docker/github-runner-fleet", - LINUX_DOCKER_RUNNER_BASE_DIR: "/srv/github-runner-fleet/linux-docker" + LINUX_DOCKER_RUNNER_BASE_DIR: "/srv/github-runner-fleet/linux-docker", + ...raw } }; } diff --git a/test/env.test.ts b/test/env.test.ts index 8369e6e..ef22247 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -158,4 +158,54 @@ describe("loadDeploymentEnv", () => { expect(env.synologySecure).toBe(false); expect(env.synologyPort).toBe("5000"); }); + + test("accepts alternate truthy and falsey boolean spellings", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "synology-env-")); + tempPaths.push(directory); + const envPath = path.join(directory, ".env"); + + fs.writeFileSync( + envPath, + "SYNOLOGY_SECURE=on\nSYNOLOGY_CERT_VERIFY=no\n", + "utf8" + ); + + const env = loadDeploymentEnv({ + envPath, + requirePat: false + }); + + expect(env.synologySecure).toBe(true); + expect(env.synologyCertVerify).toBe(false); + }); + + test("rejects malformed boolean settings", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "synology-env-")); + tempPaths.push(directory); + const envPath = path.join(directory, ".env"); + + fs.writeFileSync(envPath, "SYNOLOGY_SECURE=maybe\n", "utf8"); + + expect(() => + loadDeploymentEnv({ + envPath, + requirePat: false + }) + ).toThrow(/invalid boolean value "maybe"/); + }); + + test("rejects malformed integer settings", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "synology-env-")); + tempPaths.push(directory); + const envPath = path.join(directory, ".env"); + + fs.writeFileSync(envPath, "SYNOLOGY_DSM_VERSION=seven\n", "utf8"); + + expect(() => + loadDeploymentEnv({ + envPath, + requirePat: false + }) + ).toThrow(/invalid integer value "seven"/); + }); }); diff --git a/test/linux-docker-compose.test.ts b/test/linux-docker-compose.test.ts index 258b8f8..6ce5e59 100644 --- a/test/linux-docker-compose.test.ts +++ b/test/linux-docker-compose.test.ts @@ -31,6 +31,33 @@ describe("renderLinuxDockerCompose", () => { "com.github-runner-fleet.docker-capable": "true" }); }); + + test("renders OTEL environment for Docker-capable runners", () => { + const config = configFixture(); + config.pools[0].telemetry = { + enabled: true, + endpoint: "grpc://otel.example.com:4317", + tracesExporter: "otlp", + metricsExporter: "otlp", + logsExporter: "none", + resourceAttributes: {} + }; + + const compose = renderLinuxDockerCompose(config, envFixture()); + const payload = YAML.parse(compose.split("\n").slice(2).join("\n")) as { + services: Record>; + }; + + expect( + payload.services["linux-docker-private-runner-01"].environment + ).toMatchObject({ + OTEL_EXPORTER_OTLP_ENDPOINT: "grpc://otel.example.com:4317", + OTEL_SERVICE_NAME: "github-runner-fleet.linux-docker", + OTEL_LOGS_EXPORTER: "none", + OTEL_RESOURCE_ATTRIBUTES: + "deployment.environment=private,github.organization=example,runner.group=linux-docker-private,runner.name=linux-docker-private-runner-01,runner.pool=linux-docker-private,runner.plane=linux-docker" + }); + }); }); function configFixture(): ResolvedLinuxDockerConfig { diff --git a/test/lume-config.test.ts b/test/lume-config.test.ts index 479c62d..87f3395 100644 --- a/test/lume-config.test.ts +++ b/test/lume-config.test.ts @@ -116,6 +116,42 @@ pool: "export RUNNER_LABELS='self-hosted,macos,arm64,private'" ); }); + + test("renders OTEL shell exports when telemetry is enabled", () => { + const directory = createTempDir(); + const configPath = path.join(directory, "lume-runners.yaml"); + + fs.writeFileSync( + configPath, + `version: 1 +pool: + key: macos-private + size: 1 + vmBaseName: macos-runner-base + vmSlotPrefix: macos-runner-slot + telemetry: + enabled: true + endpoint: https://otel.example.com:4318 + protocol: http/protobuf + resourceAttributes: + service.namespace: runner-fleet +`, + "utf8" + ); + + const config = loadLumeConfig(configPath, deploymentEnv()); + const shellExports = renderLumeShellExports(config, 1); + + expect(shellExports).toContain( + "export OTEL_EXPORTER_OTLP_ENDPOINT='https://otel.example.com:4318'" + ); + expect(shellExports).toContain( + "export OTEL_EXPORTER_OTLP_PROTOCOL='http/protobuf'" + ); + expect(shellExports).toContain( + "export OTEL_RESOURCE_ATTRIBUTES='github.organization=omt-global,runner.group=macos-private,runner.name=macos-runner-slot-01,runner.pool=macos-private,runner.plane=lume,runner.slot=slot-01,service.namespace=runner-fleet'" + ); + }); }); function deploymentEnv(): DeploymentEnv { diff --git a/test/runner-version.test.ts b/test/runner-version.test.ts index 28a03ec..e88684d 100644 --- a/test/runner-version.test.ts +++ b/test/runner-version.test.ts @@ -29,4 +29,12 @@ describe("runner version helpers", () => { outdated: true }); }); + + test("keeps current runner versions marked up to date", () => { + expect(summarizeRunnerVersion("v2.327.1", "2.327.1")).toEqual({ + current: "2.327.1", + latest: "2.327.1", + outdated: false + }); + }); }); diff --git a/test/windows-compose.test.ts b/test/windows-compose.test.ts index d92ff23..0778a38 100644 --- a/test/windows-compose.test.ts +++ b/test/windows-compose.test.ts @@ -34,6 +34,32 @@ describe("renderWindowsDockerCompose", () => { "com.github-runner-fleet.docker-capable": "true" }); }); + + test("renders OTEL environment for Windows Docker runners", () => { + const config = configFixture(); + config.pools[0].telemetry = { + enabled: true, + endpoint: "https://otel.example.com:4318", + protocol: "http/protobuf", + tracesExporter: "otlp", + metricsExporter: "none", + logsExporter: "otlp", + resourceAttributes: {} + }; + + const compose = renderWindowsDockerCompose(config, envFixture()); + const payload = YAML.parse(compose.split("\n").slice(2).join("\n")) as { + services: Record>; + }; + + expect(payload.services["windows-private-runner-01"].environment).toMatchObject({ + OTEL_EXPORTER_OTLP_ENDPOINT: "https://otel.example.com:4318", + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_METRICS_EXPORTER: "none", + OTEL_RESOURCE_ATTRIBUTES: + "deployment.environment=private,github.organization=example,runner.group=windows-private,runner.name=windows-private-runner-01,runner.pool=windows-private,runner.plane=windows-docker" + }); + }); }); function configFixture(): ResolvedWindowsDockerConfig {