feat: api plugin system & offline versioned dependency vendoring (#1252)

- **New Features**
- Created a dynamic plugin system for the API to enable community
augmentation of GraphQL, CLI, and Cron functionalities capabilities.
- Included an example plugin under `packages/unraid-api-plugin-health`
that adds a new graphql query for API health checks.
- Added `rc.unraid-api` commands for backing up, restoring, and
installing production dependencies, streamlining maintenance and
deployment.
- Improved dependency vendoring by bundling a versioned pnpm store
(instead of `node_modules`). Versioning will allow users to add plugins
to a specific api release without requiring an internet connection on
subsequent reboots.

- **Chores**
- Upgraded build workflows and versioning processes to ensure more
reliable artifact handling and production packaging.
This commit is contained in:
Pujit Mehrotra
2025-03-27 13:23:55 -04:00
committed by GitHub
parent c4b4d26af0
commit 9f492bf217
28 changed files with 826 additions and 2245 deletions

View File

@@ -152,6 +152,11 @@ jobs:
with:
name: unraid-api
path: ${{ github.workspace }}/api/deploy/unraid-api.tgz
- name: Upload PNPM Store to Github artifacts
uses: actions/upload-artifact@v4
with:
name: packed-pnpm-store
path: ${{ github.workspace }}/api/deploy/packed-pnpm-store.txz
build-unraid-ui-webcomponents:
name: Build Unraid UI Library (Webcomponent Version)
@@ -316,6 +321,15 @@ jobs:
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Get API Version
id: vars
run: |
GIT_SHA=$(git rev-parse --short HEAD)
IS_TAGGED=$(git describe --tags --abbrev=0 --exact-match || echo '')
PACKAGE_LOCK_VERSION=$(jq -r '.version' package.json)
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
echo "API_VERSION=${API_VERSION}" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
@@ -347,6 +361,11 @@ jobs:
with:
name: unraid-api
path: ${{ github.workspace }}/plugin/api/
- name: Download PNPM Store
uses: actions/download-artifact@v4
with:
name: packed-pnpm-store
path: ${{ github.workspace }}/plugin/
- name: Extract Unraid API
run: |
mkdir -p ${{ github.workspace }}/plugin/source/dynamix.unraid.net/usr/local/unraid-api
@@ -355,6 +374,7 @@ jobs:
id: build-plugin
run: |
cd ${{ github.workspace }}/plugin
ls -al
pnpm run build:txz
if [ -n "${{ github.event.pull_request.number }}" ]; then
@@ -375,7 +395,6 @@ jobs:
echo "TAG=${TAG}" >> $GITHUB_OUTPUT
pnpm run build:plugin --tag="${TAG}" --base-url="${BASE_URL}"
- name: Ensure Plugin Files Exist
run: |
if [ ! -f ./deploy/*.plg ]; then
@@ -387,6 +406,7 @@ jobs:
echo "Error: .txz file not found in plugin/deploy/"
exit 1
fi
ls -al ./deploy
- name: Upload to GHA
uses: actions/upload-artifact@v4
with:

View File

@@ -77,6 +77,7 @@
"cacheable-lookup": "^7.0.0",
"camelcase-keys": "^9.1.3",
"casbin": "^5.32.0",
"change-case": "^5.4.4",
"chokidar": "^4.0.1",
"cli-table": "^0.3.11",
"command-exists": "^1.2.9",
@@ -186,6 +187,7 @@
"rollup-plugin-node-externals": "^8.0.0",
"standard-version": "^9.5.0",
"tsx": "^4.19.2",
"type-fest": "^4.37.0",
"typescript": "^5.6.3",
"typescript-eslint": "^8.13.0",
"unplugin-swc": "^1.5.1",

View File

@@ -25,6 +25,8 @@ try {
// Update the package.json version to the deployment version
parsedPackageJson.version = deploymentVersion;
// omit dev dependencies from release build
parsedPackageJson.devDependencies = {};
// Create a temporary directory for packaging
await mkdir('./deploy/pack/', { recursive: true });
@@ -36,9 +38,18 @@ try {
// Change to the pack directory and install dependencies
cd('./deploy/pack');
console.log('Installing production dependencies...');
console.log('Building production pnpm store...');
$.verbose = true;
await $`pnpm install --prod --ignore-workspace --node-linker hoisted`;
await $`pnpm install --prod --ignore-workspace --store-dir=../.pnpm-store`;
await $`rm -rf node_modules`; // Don't include node_modules in final package
const sudoCheck = await $`command -v sudo`.nothrow();
const SUDO = sudoCheck.exitCode === 0 ? 'sudo' : '';
await $`${SUDO} chown -R 0:0 ../.pnpm-store`;
await $`XZ_OPT=-5 tar -cJf ../packed-pnpm-store.txz ../.pnpm-store`;
await $`${SUDO} rm -rf ../.pnpm-store`;
// chmod the cli
await $`chmod +x ./dist/cli.js`;

View File

@@ -20,7 +20,16 @@ const getUnraidApiLocation = async () => {
};
try {
await CommandFactory.run(CliModule, {
// Register plugins and create a dynamic module configuration
const dynamicModule = await CliModule.registerWithPlugins();
// Create a new class that extends CliModule with the dynamic configuration
const DynamicCliModule = class extends CliModule {
static module = dynamicModule.module;
static imports = dynamicModule.imports;
static providers = dynamicModule.providers;
};
await CommandFactory.run(DynamicCliModule, {
cliName: 'unraid-api',
logger: LOG_LEVEL === 'TRACE' ? new LogService() : false, // - enable this to see nest initialization issues
completion: {

View File

@@ -3,36 +3,57 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const getPackageJsonVersion = () => {
import type { PackageJson, SetRequired } from 'type-fest';
/**
* Tries to get the package.json at the given location.
* @param location - The location of the package.json file, relative to the current file
* @returns The package.json object or undefined if unable to read
*/
function readPackageJson(location: string): PackageJson | undefined {
try {
// Try different possible locations for package.json
const possibleLocations = ['../package.json', '../../package.json'];
for (const location of possibleLocations) {
try {
const packageJsonUrl = import.meta.resolve(location);
const packageJsonPath = fileURLToPath(packageJsonUrl);
const packageJson = readFileSync(packageJsonPath, 'utf-8');
const packageJsonObject = JSON.parse(packageJson);
if (packageJsonObject.version) {
return packageJsonObject.version;
}
} catch {
// Continue to next location if this one fails
}
let packageJsonPath: string;
try {
const packageJsonUrl = import.meta.resolve(location);
packageJsonPath = fileURLToPath(packageJsonUrl);
} catch {
// Fallback (e.g. for local development): resolve the path relative to this module
packageJsonPath = fileURLToPath(new URL(location, import.meta.url));
}
// If we get here, we couldn't find a valid package.json in any location
console.error('Could not find package.json in any of the expected locations');
return undefined;
} catch (error) {
console.error('Failed to load package.json:', error);
const packageJsonRaw = readFileSync(packageJsonPath, 'utf-8');
return JSON.parse(packageJsonRaw) as PackageJson;
} catch {
return undefined;
}
}
/**
* Retrieves the Unraid API package.json. Throws if unable to find.
* This should be considered a fatal error.
*
* @returns The package.json object
*/
export const getPackageJson = () => {
const packageJson = readPackageJson('../package.json') || readPackageJson('../../package.json');
if (!packageJson) {
throw new Error('Could not find package.json in any of the expected locations');
}
return packageJson as SetRequired<PackageJson, 'version' | 'dependencies'>;
};
export const API_VERSION =
process.env.npm_package_version ?? getPackageJsonVersion() ?? new Error('API_VERSION not set');
/**
* Returns list of runtime dependencies from the Unraid-API package.json. Returns undefined if
* the package.json or its dependency object cannot be found or read.
*
* Does not log or produce side effects.
* @returns The names of all runtime dependencies. Undefined if failed.
*/
export const getPackageJsonDependencies = (): string[] | undefined => {
const { dependencies } = getPackageJson();
return Object.keys(dependencies);
};
export const API_VERSION = process.env.npm_package_version ?? getPackageJson().version;
export const NODE_ENV =
(process.env.NODE_ENV as 'development' | 'test' | 'staging' | 'production') ?? 'production';

View File

@@ -2,7 +2,7 @@ import { mergeTypeDefs } from '@graphql-tools/merge';
import { logger } from '@app/core/log.js';
export const loadTypeDefs = async () => {
export const loadTypeDefs = async (additionalTypeDefs: string[] = []) => {
// TypeScript now knows this returns Record<string, () => Promise<string>>
const typeModules = import.meta.glob('./types/**/*.graphql', { query: '?raw', import: 'default' });
@@ -19,6 +19,7 @@ export const loadTypeDefs = async () => {
if (!files.length) {
throw new Error('No GraphQL type definitions found');
}
files.push(...additionalTypeDefs);
return mergeTypeDefs(files);
} catch (error) {
logger.error('Failed to load GraphQL type definitions:', error);

View File

@@ -35,6 +35,7 @@ export const store = configureStore({
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export type ApiStore = typeof store;
export const getters = {
cache: () => store.getState().cache,

View File

@@ -11,6 +11,7 @@ import { GraphqlAuthGuard } from '@app/unraid-api/auth/auth.guard.js';
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
import { CronModule } from '@app/unraid-api/cron/cron.module.js';
import { GraphModule } from '@app/unraid-api/graph/graph.module.js';
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
import { RestModule } from '@app/unraid-api/rest/rest.module.js';
import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.module.js';
@@ -46,6 +47,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
},
]),
UnraidFileModifierModule,
PluginModule.registerPlugins(),
],
controllers: [],
providers: [

View File

@@ -1,4 +1,6 @@
import { Module } from '@nestjs/common';
import { DynamicModule, Module, Provider, Type } from '@nestjs/common';
import { CommandRunner } from 'nest-commander';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js';
@@ -23,32 +25,76 @@ import { StatusCommand } from '@app/unraid-api/cli/status.command.js';
import { StopCommand } from '@app/unraid-api/cli/stop.command.js';
import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command.js';
import { VersionCommand } from '@app/unraid-api/cli/version.command.js';
import { ApiPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js';
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
const DEFAULT_COMMANDS = [
ApiKeyCommand,
ConfigCommand,
DeveloperCommand,
LogsCommand,
ReportCommand,
RestartCommand,
StartCommand,
StatusCommand,
StopCommand,
SwitchEnvCommand,
VersionCommand,
SSOCommand,
ValidateTokenCommand,
AddSSOUserCommand,
RemoveSSOUserCommand,
ListSSOUserCommand,
] as const;
const DEFAULT_PROVIDERS = [
AddApiKeyQuestionSet,
AddSSOUserQuestionSet,
RemoveSSOUserQuestionSet,
DeveloperQuestions,
LogService,
PM2Service,
ApiKeyService,
] as const;
type PluginProvider = Provider & {
provide: string | symbol | Type<any>;
useValue?: ApiPluginDefinition;
};
@Module({
providers: [
AddSSOUserCommand,
AddSSOUserQuestionSet,
RemoveSSOUserCommand,
RemoveSSOUserQuestionSet,
ListSSOUserCommand,
LogService,
PM2Service,
StartCommand,
StopCommand,
RestartCommand,
ReportCommand,
ApiKeyService,
ApiKeyCommand,
AddApiKeyQuestionSet,
SwitchEnvCommand,
VersionCommand,
StatusCommand,
SSOCommand,
ValidateTokenCommand,
LogsCommand,
ConfigCommand,
DeveloperCommand,
DeveloperQuestions,
],
imports: [PluginModule],
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
})
export class CliModule {}
export class CliModule {
/**
* Get all registered commands
* @returns Array of registered command classes
*/
static getCommands(): Type<CommandRunner>[] {
return [...DEFAULT_COMMANDS];
}
/**
* Register the module with plugin support
* @returns DynamicModule configuration including plugin commands
*/
static async registerWithPlugins(): Promise<DynamicModule> {
const pluginModule = await PluginModule.registerPlugins();
// Get commands from plugins
const pluginCommands: Type<CommandRunner>[] = [];
for (const provider of (pluginModule.providers || []) as PluginProvider[]) {
if (provider.provide !== PluginService && provider.useValue?.commands) {
pluginCommands.push(...provider.useValue.commands);
}
}
return {
module: CliModule,
imports: [pluginModule],
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS, ...pluginCommands],
};
}
}

View File

@@ -24,38 +24,46 @@ import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.modul
import { sandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
import { ServicesResolver } from '@app/unraid-api/graph/services/services.resolver.js';
import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js';
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
@Module({
imports: [
ResolversModule,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
useFactory: async () => ({
introspection: getters.config()?.local?.sandbox === 'yes',
playground: false,
context: ({ req, connectionParams, extra }: any) => ({
req,
connectionParams,
extra,
}),
plugins: [sandboxPlugin, idPrefixPlugin] as any[],
subscriptions: {
'graphql-ws': {
path: '/graphql',
imports: [PluginModule],
inject: [PluginService],
useFactory: async (pluginService: PluginService) => {
const plugins = await pluginService.getGraphQLConfiguration();
return {
introspection: getters.config()?.local?.sandbox === 'yes',
playground: false,
context: ({ req, connectionParams, extra }: any) => ({
req,
connectionParams,
extra,
}),
plugins: [sandboxPlugin, idPrefixPlugin] as any[],
subscriptions: {
'graphql-ws': {
path: '/graphql',
},
},
},
path: '/graphql',
typeDefs: print(await loadTypeDefs()),
resolvers: {
JSON: JSONResolver,
Long: GraphQLLong,
UUID: UUIDResolver,
DateTime: DateTimeResolver,
Port: PortResolver,
URL: URLResolver,
},
validationRules: [NoUnusedVariablesRule],
}),
path: '/graphql',
typeDefs: [print(await loadTypeDefs([plugins.typeDefs]))],
resolvers: {
JSON: JSONResolver,
Long: GraphQLLong,
UUID: UUIDResolver,
DateTime: DateTimeResolver,
Port: PortResolver,
URL: URLResolver,
...plugins.resolvers,
},
validationRules: [NoUnusedVariablesRule],
};
},
}),
],
providers: [

View File

@@ -0,0 +1,61 @@
import { Logger, Type } from '@nestjs/common';
import { CommandRunner } from 'nest-commander';
import { z } from 'zod';
import { ApiStore } from '@app/store/index.js';
export interface PluginMetadata {
name: string;
description: string;
}
const asyncArray = () => z.function().returns(z.promise(z.array(z.any())));
const asyncString = () => z.function().returns(z.promise(z.string()));
const asyncVoid = () => z.function().returns(z.promise(z.void()));
// GraphQL resolver type definitions
const resolverFunction = z
.function()
.args(
z.any().optional(), // parent
z.any().optional(), // args
z.any().optional(), // context
z.any().optional() // info
)
.returns(z.any());
const resolverFieldMap = z.record(z.string(), resolverFunction);
const resolverTypeMap = z.record(
z.enum(['Query', 'Mutation', 'Subscription']).or(z.string()),
resolverFieldMap
);
const asyncResolver = () => z.function().returns(z.promise(resolverTypeMap));
/** Warning: unstable API. The config mechanism and API may soon change. */
export const apiPluginSchema = z.object({
_type: z.literal('UnraidApiPlugin'),
name: z.string(),
description: z.string(),
commands: z.array(z.custom<Type<CommandRunner>>()),
registerGraphQLResolvers: asyncResolver().optional(),
registerGraphQLTypeDefs: asyncString().optional(),
registerRESTControllers: asyncArray().optional(),
registerRESTRoutes: asyncArray().optional(),
registerServices: asyncArray().optional(),
registerCronJobs: asyncArray().optional(),
// These schema definitions are picked up as nest modules as well.
onModuleInit: asyncVoid().optional(),
onModuleDestroy: asyncVoid().optional(),
});
/** Warning: unstable API. The config mechanism and API may soon change. */
export type ApiPluginDefinition = z.infer<typeof apiPluginSchema>;
// todo: the blocker to publishing this type is the 'ApiStore' type.
// It pulls in a lot of irrelevant types (e.g. graphql types) and triggers js transpilation of everything related to the store.
// If we can isolate the type, we can publish it to npm and developers can use it as a dev dependency.
/**
* Represents a subclass of UnraidAPIPlugin that can be instantiated.
*/
export type ConstructablePlugin = (options: { store: ApiStore; logger: Logger }) => ApiPluginDefinition;

View File

@@ -0,0 +1,20 @@
import { DynamicModule, Logger, Module } from '@nestjs/common';
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
@Module({})
export class PluginModule {
private static readonly logger = new Logger(PluginModule.name);
constructor(private readonly pluginService: PluginService) {}
static async registerPlugins(): Promise<DynamicModule> {
const plugins = await PluginService.getPlugins();
const providers = plugins.map((result) => result.provider);
return {
module: PluginModule,
providers: [PluginService, ...providers],
exports: [PluginService, ...providers.map((p) => p.provide)],
global: true,
};
}
}

View File

@@ -0,0 +1,225 @@
import { Injectable, Logger, Provider, Type } from '@nestjs/common';
import { pascalCase } from 'change-case';
import { parse } from 'graphql';
import type {
ApiPluginDefinition,
ConstructablePlugin,
} from '@app/unraid-api/plugin/plugin.interface.js';
import { getPackageJsonDependencies as getPackageDependencies } from '@app/environment.js';
import { store } from '@app/store/index.js';
import { apiPluginSchema } from '@app/unraid-api/plugin/plugin.interface.js';
import { batchProcess } from '@app/utils.js';
type CustomProvider = Provider & {
provide: string | symbol | Type<any>;
};
type PluginProvider = {
provider: CustomProvider;
pluginInstance: ApiPluginDefinition;
};
@Injectable()
export class PluginService {
private pluginProviders: PluginProvider[] | undefined;
private loadingPromise: Promise<PluginProvider[]> | undefined;
private static readonly logger = new Logger(PluginService.name);
constructor() {
this.loadPlugins();
}
private get plugins() {
return this.pluginProviders?.map((plugin) => plugin.pluginInstance) ?? [];
}
async loadPlugins() {
// If plugins are already loaded, return them
if (this.pluginProviders?.length) {
return this.pluginProviders;
}
// If getPlugins() is already loading, return its promise
if (this.loadingPromise) {
return this.loadingPromise;
}
this.loadingPromise = PluginService.getPlugins()
.then((plugins) => {
if (!this.pluginProviders?.length) {
this.pluginProviders = plugins;
const pluginNames = this.plugins.map((plugin) => plugin.name);
PluginService.logger.debug(
`Registered ${pluginNames.length} plugins: ${pluginNames.join(', ')}`
);
} else {
PluginService.logger.debug(
`${plugins.length} plugins already registered. Skipping registration.`
);
}
return this.pluginProviders;
})
.catch((error) => {
PluginService.logger.error('Error registering plugins', error);
return [];
})
.finally(() => {
// clear loading state
this.loadingPromise = undefined;
});
return this.loadingPromise;
}
private static isPluginFactory(factory: unknown): factory is ConstructablePlugin {
return typeof factory === 'function';
}
private static async getPluginFromPackage(pluginPackage: string): Promise<{
provider: CustomProvider;
pluginInstance: ApiPluginDefinition;
}> {
const moduleImport = await import(/* @vite-ignore */ pluginPackage);
const pluginName = pascalCase(pluginPackage);
const PluginFactory = moduleImport.default || moduleImport[pluginName];
if (!PluginService.isPluginFactory(PluginFactory)) {
throw new Error(`Invalid plugin from ${pluginPackage}. Must export a factory function.`);
}
const logger = new Logger(PluginFactory.name);
const validation = apiPluginSchema.safeParse(PluginFactory({ store, logger }));
if (!validation.success) {
throw new Error(`Invalid plugin from ${pluginPackage}: ${validation.error}`);
}
const pluginInstance = validation.data;
return {
provider: {
provide: PluginFactory.name,
useValue: pluginInstance,
},
pluginInstance,
};
}
static async getPlugins() {
/** All api plugins must be npm packages whose name starts with this prefix */
const pluginPrefix = 'unraid-api-plugin-';
// All api plugins must be installed as dependencies of the unraid-api package
/** list of npm packages that are unraid-api plugins */
const plugins = getPackageDependencies()?.filter((pkgName) => pkgName.startsWith(pluginPrefix));
if (!plugins) {
PluginService.logger.warn('Could not load dependencies from the Unraid-API package.json');
// Fail silently: Return the module without plugins
return [];
}
const failedPlugins: string[] = [];
const { data: pluginProviders } = await batchProcess(plugins, async (pluginPackage) => {
try {
return await PluginService.getPluginFromPackage(pluginPackage);
} catch (error) {
failedPlugins.push(pluginPackage);
PluginService.logger.warn(error);
throw error;
}
});
if (failedPlugins.length > 0) {
PluginService.logger.warn(
`${failedPlugins.length} plugins failed to load. Ignoring them: ${failedPlugins.join(', ')}`
);
}
return pluginProviders;
}
async getGraphQLConfiguration() {
await this.loadPlugins();
const plugins = this.plugins;
let combinedResolvers = {};
const typeDefs: string[] = [];
for (const plugin of plugins) {
if (plugin.registerGraphQLResolvers) {
const pluginResolvers = await plugin.registerGraphQLResolvers();
combinedResolvers = {
...combinedResolvers,
...pluginResolvers,
};
}
if (plugin.registerGraphQLTypeDefs) {
const pluginTypeDefs = await plugin.registerGraphQLTypeDefs();
try {
// Validate schema by parsing it - this will throw if invalid
parse(pluginTypeDefs);
typeDefs.push(pluginTypeDefs);
} catch (error) {
const errorMessage = `Plugin ${plugin.name} returned an unusable GraphQL type definition: ${JSON.stringify(
pluginTypeDefs
)}`;
PluginService.logger.warn(errorMessage);
}
}
}
return {
resolvers: combinedResolvers,
typeDefs: typeDefs.join('\n'),
};
}
async getRESTConfiguration() {
await this.loadPlugins();
const controllers: Type<any>[] = [];
const routes: Record<string, any>[] = [];
for (const plugin of this.plugins) {
if (plugin.registerRESTControllers) {
const pluginControllers = await plugin.registerRESTControllers();
controllers.push(...pluginControllers);
}
if (plugin.registerRESTRoutes) {
const pluginRoutes = await plugin.registerRESTRoutes();
routes.push(...pluginRoutes);
}
}
return {
controllers,
routes,
};
}
async getServices() {
await this.loadPlugins();
const services: Type<any>[] = [];
for (const plugin of this.plugins) {
if (plugin.registerServices) {
const pluginServices = await plugin.registerServices();
services.push(...pluginServices);
}
}
return services;
}
async getCronJobs() {
await this.loadPlugins();
const cronJobs: Record<string, any>[] = [];
for (const plugin of this.plugins) {
if (plugin.registerCronJobs) {
const pluginCronJobs = await plugin.registerCronJobs();
cronJobs.push(...pluginCronJobs);
}
}
return cronJobs;
}
}

View File

@@ -0,0 +1,33 @@
export default ({ store, logger }) => ({
_type: "UnraidApiPlugin",
name: "HealthPlugin",
description: "Health plugin",
commands: [],
async registerGraphQLResolvers() {
return {
Query: {
health: () => {
logger.log("Pinged health");
return "OK";
}
}
};
},
async registerGraphQLTypeDefs() {
return `
type Query {
health: String
}
`;
},
async onModuleInit() {
logger.log("Health plugin initialized");
},
async onModuleDestroy() {
logger.log("Health plugin destroyed");
},
});

View File

@@ -0,0 +1,13 @@
{
"name": "unraid-api-plugin-health",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "Lime Technology, Inc. <unraid.net>",
"license": "GPL-2.0-only",
"description": "Example Health plugin for Unraid API"
}

6
plugin/.gitignore vendored
View File

@@ -13,4 +13,8 @@ source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-com
!source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/.gitkeep
source/dynamix.unraid.net/usr/local/unraid-api/*
!source/dynamix.unraid.net/usr/local/unraid-api/.gitkeep
!source/dynamix.unraid.net/usr/local/unraid-api/.gitkeep
source/dynamix.unraid.net/install/doinst.sh
packed-pnpm-store.txz

View File

@@ -3,7 +3,7 @@ import { $ } from "zx";
import { escape as escapeHtml } from "html-sloppy-escaper";
import { dirname, join } from "node:path";
import { getTxzName, pluginName, startingDir } from "./utils/consts";
import { getPluginUrl } from "./utils/bucket-urls";
import { getAssetUrl, getPluginUrl } from "./utils/bucket-urls";
import { getMainTxzUrl } from "./utils/bucket-urls";
import {
deployDir,
@@ -12,6 +12,7 @@ import {
} from "./utils/paths";
import { PluginEnv, setupPluginEnv } from "./cli/setup-plugin-environment";
import { cleanupPluginFiles } from "./utils/cleanup";
import { bundlePnpmStore, getPnpmBundleName } from "./build-pnpm-store";
/**
* Check if git is available
@@ -60,6 +61,8 @@ const buildPlugin = async ({
pluginURL: getPluginUrl({ baseUrl, tag }),
MAIN_TXZ: getMainTxzUrl({ baseUrl, pluginVersion, tag }),
TXZ_SHA256: txzSha256,
VENDOR_STORE_URL: getAssetUrl({ baseUrl, tag }, getPnpmBundleName()),
VENDOR_STORE_FILENAME: getPnpmBundleName(),
...(tag ? { TAG: tag } : {}),
};
@@ -67,7 +70,9 @@ const buildPlugin = async ({
// Iterate over entities and update them
Object.entries(entities).forEach(([key, value]) => {
if (!value) {
throw new Error(`Entity ${key} not set in entities: ${JSON.stringify(entities)}`);
throw new Error(
`Entity ${key} not set in entities: ${JSON.stringify(entities)}`
);
}
plgContent = updateEntityValue(plgContent, key, value);
});
@@ -94,11 +99,16 @@ const buildPlugin = async ({
const main = async () => {
try {
const validatedEnv = await setupPluginEnv(process.argv);
await checkGit();
if (validatedEnv.tag === "LOCAL_PLUGIN_BUILD") {
console.log("Skipping git check for LOCAL_PLUGIN_BUILD");
} else {
await checkGit();
}
await cleanupPluginFiles();
await buildPlugin(validatedEnv);
await moveTxzFile(validatedEnv.txzPath, validatedEnv.pluginVersion);
await bundlePnpmStore();
} catch (error) {
console.error(error);
process.exit(1);

View File

@@ -0,0 +1,42 @@
import { apiDir, deployDir } from "./utils/paths";
import { join } from "path";
import { readFileSync } from "node:fs";
import { startingDir } from "./utils/consts";
import { copyFile } from "node:fs/promises";
/**
* Get the version of the API from the package.json file
*
* Throws if package.json is not found or is invalid JSON.
* @returns The version of the API
*/
function getVersion(): string {
const packageJsonPath = join(apiDir, "package.json");
const packageJsonString = readFileSync(packageJsonPath, "utf8");
const packageJson = JSON.parse(packageJsonString);
return packageJson.version;
}
/**
* The name of the pnpm store archive that will be vendored with the plugin.
* @returns The name of the pnpm store bundle file
*/
export function getPnpmBundleName(): string {
const version = getVersion();
return `pnpm-store-for-v${version}.txz`;
}
/**
* Prepare a versioned bundle of the API's pnpm store to vendor dependencies.
*
* It expects a generic `packed-pnpm-store.txz` archive to be available in the `startingDir`.
* It copies this archive to the `deployDir` directory and adds a version to the filename.
* It does not actually create the packed pnpm store archive; that is done inside the API's build script.
*
* After this operation, the vendored store will be available inside the `deployDir`.
*/
export async function bundlePnpmStore(): Promise<void> {
const storeArchive = join(startingDir, "packed-pnpm-store.txz");
const pnpmStoreTarPath = join(deployDir, getPnpmBundleName());
await copyFile(storeArchive, pnpmStoreTarPath);
}

View File

@@ -5,6 +5,7 @@ import { readdir } from "node:fs/promises";
import { getTxzName, pluginName, startingDir } from "./utils/consts";
import { setupTxzEnv, TxzEnv } from "./cli/setup-txz-environment";
import { cleanupTxzFiles } from "./utils/cleanup";
import { apiDir } from "./utils/paths";
// Recursively search for manifest files
const findManifestFiles = async (dir: string): Promise<string[]> => {
@@ -74,14 +75,6 @@ const validateSourceDir = async (validatedEnv: TxzEnv) => {
);
}
const apiDir = join(
startingDir,
"source",
pluginName,
"usr",
"local",
"unraid-api"
);
if (!existsSync(apiDir)) {
throw new Error(`API directory ${apiDir} does not exist`);
}

View File

@@ -26,24 +26,25 @@ const getRootBucketPath = ({ baseUrl, tag }: UrlParams): URL => {
return url;
};
/**
* Get the URL for an asset from the root bucket
* ex. returns = BASE_URL/TAG/dynamix.unraid.net.plg
*/
export const getAssetUrl = (params: UrlParams, assetName: string): string => {
const rootUrl = getRootBucketPath(params);
rootUrl.pathname = rootUrl.pathname.replace(/\/?$/, "/") + assetName;
return rootUrl.toString();
};
/**
* Get the URL for the plugin file
* ex. returns = BASE_URL/TAG/dynamix.unraid.net.plg
*/
export const getPluginUrl = (params: UrlParams): string => {
const rootUrl = getRootBucketPath(params);
// Ensure the path ends with a slash and join with the plugin name
rootUrl.pathname = rootUrl.pathname.replace(/\/?$/, "/") + pluginNameWithExt;
return rootUrl.toString();
};
export const getPluginUrl = (params: UrlParams): string =>
getAssetUrl(params, pluginNameWithExt);
/**
* Get the URL for the main TXZ file
* ex. returns = BASE_URL/TAG/dynamix.unraid.net-4.1.3.txz
*/
export const getMainTxzUrl = (params: TxzUrlParams): string => {
const rootUrl = getRootBucketPath(params);
// Ensure the path ends with a slash and join with the txz name
rootUrl.pathname = rootUrl.pathname.replace(/\/?$/, "/") + getTxzName(params.pluginVersion);
return rootUrl.toString();
};
export const getMainTxzUrl = (params: TxzUrlParams): string =>
getAssetUrl(params, getTxzName(params.pluginVersion));

View File

@@ -34,6 +34,7 @@ export const getStagingChangelogFromGit = async ({
// Encode HTML entities using the 'he' library
return changelog ?? "";
} catch (err) {
throw new Error(`Failed to get changelog from git: ${err}`);
console.log('Non-fatal error: Failed to get changelog from git:', err);
return tag;
}
};

View File

@@ -1,5 +1,10 @@
import { join } from "path";
import { getTxzName, pluginNameWithExt } from "./consts";
import {
getTxzName,
pluginName,
pluginNameWithExt,
startingDir,
} from "./consts";
export interface PathConfig {
startingDir: string;
@@ -11,6 +16,15 @@ export interface TxzPathConfig extends PathConfig {
export const deployDir = "deploy" as const;
export const apiDir = join(
startingDir,
"source",
pluginName,
"usr",
"local",
"unraid-api"
);
/**
* Get the path to the root plugin directory
* @param startingDir - The starting directory

View File

@@ -12,6 +12,7 @@ services:
- ../unraid-ui/dist-wc:/app/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/uui
- ../web/.nuxt/nuxt-custom-elements/dist/unraid-components:/app/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt
- ../api/deploy/pack/:/app/source/dynamix.unraid.net/usr/local/unraid-api
- ../api/deploy/packed-pnpm-store.txz:/app/packed-pnpm-store.txz
stdin_open: true # equivalent to -i
tty: true # equivalent to -t
environment:

View File

@@ -7,15 +7,28 @@
<!ENTITY pluginURL "">
<!ENTITY source "/boot/config/plugins/dynamix.my.servers/&name;">
<!ENTITY TXZ_SHA256 "">
<!-- Node.js Runtime. Required to run the Unraid API. -->
<!ENTITY NODEJS_VERSION "22.14.0">
<!-- Version is omitted from filename, so we don't need to search/delete other versions when updating the plugin. -->
<!ENTITY NODEJS_FILENAME "node-linux-x64.tar.xz">
<!-- To get SHA256:
wget https://nodejs.org/download/release/v22.14.0/node-v22.14.0-linux-x64.tar.xz
sha256sum node-v22.14.0-linux-x64.tar.xz
-->
<!ENTITY NODEJS_FILENAME "node-v&NODEJS_VERSION;-linux-x64.tar.xz">
<!ENTITY NODEJS_SHA256 "69b09dba5c8dcb05c4e4273a4340db1005abeafe3927efda2bc5b249e80437ec">
<!ENTITY NODEJS_TXZ "https://nodejs.org/download/release/v&NODEJS_VERSION;/node-v&NODEJS_VERSION;-linux-x64.tar.xz">
<!ENTITY MAIN_TXZ "">
<!-- PNPM package manager for Node.js. Decouples dependencies from MAIN_TXZ. Prevents supply chain attacks. -->
<!-- PNPM_BINARY is the filename of the binary on the boot drive. (In)validated via SHA256. -->
<!ENTITY PNPM_BINARY "/boot/config/plugins/dynamix.my.servers/pnpm-linuxstatic-x64">
<!ENTITY PNPM_BINARY_URL "https://github.com/pnpm/pnpm/releases/download/v10.7.0/pnpm-linuxstatic-x64">
<!ENTITY PNPM_BINARY_SHA256 "714f4c21b63f47ed415f2e59f4bf5c699aa4f58b4d88e15ce6c66cda5631ebb2">
<!-- VENDOR_STORE_URL points to an XZ tarball of vendored dependencies (i.e. global pnpm store), specific to the plugin version.
This archive may be updated after installation (e.g. when adding api plugins), so we don't verify its hash.
It is replaced only when the plugin/api is updated. -->
<!ENTITY VENDOR_STORE_URL "">
<!-- The archive's filename on the boot drive. Enables reproducible offline installs of the Unraid API. -->
<!ENTITY VENDOR_STORE_FILENAME "">
<!ENTITY TAG "">
]>
@@ -103,9 +116,17 @@ exit 0
<URL>&NODEJS_TXZ;</URL>
<SHA256>&NODEJS_SHA256;</SHA256>
</FILE>
<FILE Name="&PNPM_BINARY;">
<URL>&PNPM_BINARY_URL;</URL>
<SHA256>&PNPM_BINARY_SHA256;</SHA256>
</FILE>
<FILE Name="/boot/config/plugins/dynamix.my.servers/&VENDOR_STORE_FILENAME;">
<URL>&VENDOR_STORE_URL;</URL>
</FILE>
<FILE Run="/bin/bash" Method="install">
<INLINE>
NODE_FILE="&NODEJS_FILENAME;"
VENDOR_ARCHIVE="&VENDOR_STORE_FILENAME;"
<![CDATA[
# Check if the Node.js archive exists
if [[ ! -f "/boot/config/plugins/dynamix.my.servers/${NODE_FILE}" ]]; then
@@ -132,8 +153,11 @@ exit 0
fi
# Remove all node js archives from the flashdrive that do not match the expected version
# deprecated Apr 2025. kept to remove unused archives for users upgrading from versioned node downloads.
find /boot/config/plugins/dynamix.my.servers/ -name "node-v*-linux-x64.tar.xz" ! -name "${NODE_FILE}" -delete
# Remove stale pnpm store archives from the boot drive
find /boot/config/plugins/dynamix.my.servers/ -name "pnpm-store-for-v*.txz" ! -name "${VENDOR_ARCHIVE}" -delete
echo "Node.js installation successful"
@@ -229,6 +253,7 @@ if [ -e /etc/rc.d/rc.unraid-api ]; then
# uninstall the api
rm -rf /usr/local/unraid-api
rm -rf /var/run/unraid-api.sock
rm -rf /usr/.pnpm-store
fi
]]>
</INLINE>
@@ -332,6 +357,8 @@ exit 0
<FILE Run="/bin/bash" Method="install">
<INLINE>
TAG="&TAG;" MAINTXZ="&source;.txz"
VENDOR_ARCHIVE="/boot/config/plugins/dynamix.my.servers/&VENDOR_STORE_FILENAME;"
PNPM_BINARY_FILE="&PNPM_BINARY;"
<![CDATA[
appendTextIfMissing() {
FILE="$1" TEXT="$2"
@@ -788,9 +815,24 @@ fi
# Create symlink to unraid-api binary (to allow usage elsewhere)
ln -sf /usr/local/node/bin/node /usr/local/bin/node
ln -sf /usr/local/node/bin/npm /usr/local/bin/npm
ln -sf /usr/local/node/bin/corepack /usr/local/bin/corepack
ln -sf ${unraid_binary_path} /usr/local/sbin/unraid-api
ln -sf ${unraid_binary_path} /usr/bin/unraid-api
cp -f "${PNPM_BINARY_FILE}" /usr/local/bin/pnpm
chmod +x /usr/local/bin/pnpm
/etc/rc.d/rc.unraid-api restore-dependencies "$VENDOR_ARCHIVE"
echo
echo "⚠️ Do not close this window yet"
/etc/rc.d/rc.unraid-api pnpm-install
echo
echo "⚠️ Do not close this window yet"
echo
echo "About to start the Unraid API"
logger "Starting flash backup (if enabled)"
echo "/etc/rc.d/rc.flash_backup start" | at -M now &>/dev/null

View File

@@ -8,7 +8,9 @@ flash="/boot/config/plugins/dynamix.my.servers"
[[ ! -d "${flash}" ]] && echo "Please reinstall the Unraid Connect plugin" && exit 1
[[ ! -f "${flash}/env" ]] && echo 'env=production' >"${flash}/env"
unraid_binary_path="/usr/local/bin/unraid-api"
pnpm_store_dir="/usr/.pnpm-store"
# Placeholder functions for plugin installation/uninstallation
install() {
true;
}
@@ -16,6 +18,115 @@ uninstall() {
true;
}
# Creates a backup of the global pnpm store directory
# Args:
# $1 - Path to the backup file (tar.xz format)
# Returns:
# 0 on success, 1 on failure
backup_pnpm_store() {
# Check if backup file path is provided
if [ -z "$1" ]; then
echo "Error: Backup file path is required"
return 1
fi
local backup_file="$1"
# Check if pnpm command exists
if ! command -v pnpm >/dev/null 2>&1; then
echo "pnpm is not installed. Skipping backup."
return 1
fi
# Determine the global pnpm store directory
mkdir -p "$pnpm_store_dir"
echo "Backing up pnpm store from '$pnpm_store_dir' to '$backup_file'"
# Create a tar.gz archive of the global pnpm store
if tar -cJf "$backup_file" -C "$(dirname "$pnpm_store_dir")" "$(basename "$pnpm_store_dir")"; then
echo "pnpm store backup completed successfully."
else
echo "Error: Failed to create pnpm store backup."
return 1
fi
}
# Restores the pnpm store from a backup file
# Args:
# $1 - Path to the backup file (tar.xz format)
# Returns:
# 0 on success, 1 on failure
# Note: Requires 1.5x the backup size in free space for safe extraction
restore_pnpm_store() {
# Check if pnpm command exists
if ! command -v pnpm >/dev/null 2>&1; then
echo "pnpm is not installed. Cannot restore store."
return 1
fi
local backup_file="$1"
# Check if backup file exists
if [ ! -f "$backup_file" ]; then
echo "Backup file not found at '$backup_file'. Skipping restore."
return 0
fi
# Check available disk space in destination
local backup_size
backup_size=$(stat -c%s "$backup_file")
local dest_space
dest_space=$(df --output=avail "$(dirname "$pnpm_store_dir")" | tail -n1)
dest_space=$((dest_space * 1024)) # Convert KB to bytes
# Require 1.5x the backup size for safe extraction
local required_space=$((backup_size + (backup_size / 2)))
if [ "$dest_space" -lt "$required_space" ]; then
echo "Error: Insufficient disk space in destination. Need at least $((required_space / 1024 / 1024))MB, have $((dest_space / 1024 / 1024))MB"
return 1
fi
echo "Restoring pnpm store from '$backup_file' to '$pnpm_store_dir'"
# Remove existing store directory if it exists and ensure its parent directory exists
rm -rf "$pnpm_store_dir"
mkdir -p "$(dirname "$pnpm_store_dir")"
# Extract directly to final location
if ! tar -xJf "$backup_file" -C "$(dirname "$pnpm_store_dir")" --preserve-permissions; then
echo "Error: Failed to extract backup to final location."
rm -rf "$pnpm_store_dir"
return 1
fi
echo "pnpm store restored successfully."
}
# Installs production dependencies for the unraid-api using pnpm. Prefers offline mode.
# Uses the api_base_directory variable or defaults to /usr/local/unraid-api
# Returns:
# 0 on success, 1 on failure
pnpm_install_unraid_api() {
# Check if pnpm command exists
if ! command -v pnpm >/dev/null 2>&1; then
echo "Error: pnpm command not found. Cannot install dependencies."
return 1
fi
# Use the api_base_directory variable if set, otherwise default to /usr/local/unraid-api
local unraid_api_dir="${api_base_directory:-/usr/local/unraid-api}"
if [ ! -d "$unraid_api_dir" ]; then
echo "Error: unraid API directory '$unraid_api_dir' does not exist."
return 1
fi
echo "Executing 'pnpm install' in $unraid_api_dir"
rm -rf /usr/local/unraid-api/node_modules
# Run pnpm install in a subshell to prevent changing the current working directory of the script
( cd "$unraid_api_dir" && pnpm install --prod --prefer-offline )
}
case "$1" in
'install')
install "$2"
@@ -26,6 +137,15 @@ case "$1" in
'uninstall')
uninstall
;;
'pnpm-install')
pnpm_install_unraid_api
;;
'backup-dependencies')
backup_pnpm_store "$2"
;;
'restore-dependencies')
restore_pnpm_store "$2"
;;
*)
# Pass all other commands to unraid-api
"${unraid_binary_path}" "$@"

File diff suppressed because it is too large Load Diff

25
pnpm-lock.yaml generated
View File

@@ -101,6 +101,9 @@ importers:
casbin:
specifier: ^5.32.0
version: 5.38.0
change-case:
specifier: ^5.4.4
version: 5.4.4
chokidar:
specifier: ^4.0.1
version: 4.0.3
@@ -423,6 +426,9 @@ importers:
tsx:
specifier: ^4.19.2
version: 4.19.3
type-fest:
specifier: ^4.37.0
version: 4.37.0
typescript:
specifier: ^5.6.3
version: 5.8.2
@@ -448,6 +454,8 @@ importers:
specifier: ^8.3.2
version: 8.4.1
packages/unraid-api-plugin-health: {}
plugin:
dependencies:
commander:
@@ -9309,7 +9317,6 @@ packages:
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
deprecated: |-
You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
qs@6.13.0:
@@ -10549,8 +10556,8 @@ packages:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'}
type-fest@4.34.1:
resolution: {integrity: sha512-6kSc32kT0rbwxD6QL1CYe8IqdzN/J/ILMrNK+HMQCKH3insCDRY/3ITb0vcBss0a3t72fzh2YSzj8ko1HgwT3g==}
type-fest@4.37.0:
resolution: {integrity: sha512-S/5/0kFftkq27FPNye0XM1e2NsnoD/3FS+pBmbjmmtLT6I+i344KoOf7pvXreaFsDamWeaJX55nczA1m5PsBDg==}
engines: {node: '>=16'}
type-fest@4.38.0:
@@ -16183,7 +16190,7 @@ snapshots:
camelcase: 8.0.0
map-obj: 5.0.0
quick-lru: 6.1.2
type-fest: 4.34.1
type-fest: 4.37.0
camelcase@5.3.1: {}
@@ -18549,7 +18556,7 @@ snapshots:
lowercase-keys: 3.0.0
p-cancelable: 4.0.1
responselike: 3.0.0
type-fest: 4.34.1
type-fest: 4.37.0
graceful-fs@4.2.10: {}
@@ -20698,7 +20705,7 @@ snapshots:
dependencies:
'@babel/code-frame': 7.26.2
index-to-position: 0.1.2
type-fest: 4.34.1
type-fest: 4.37.0
parse-json@8.2.0:
dependencies:
@@ -21444,7 +21451,7 @@ snapshots:
dependencies:
find-up-simple: 1.0.0
read-pkg: 9.0.1
type-fest: 4.34.1
type-fest: 4.37.0
read-pkg-up@3.0.0:
dependencies:
@@ -21475,7 +21482,7 @@ snapshots:
'@types/normalize-package-data': 2.4.4
normalize-package-data: 6.0.2
parse-json: 8.1.0
type-fest: 4.34.1
type-fest: 4.37.0
unicorn-magic: 0.1.0
read@1.0.7:
@@ -22784,7 +22791,7 @@ snapshots:
type-fest@2.19.0: {}
type-fest@4.34.1: {}
type-fest@4.37.0: {}
type-fest@4.38.0: {}

View File

@@ -4,3 +4,4 @@ packages:
- "./plugin"
- "./unraid-ui"
- "./web"
- "./packages/*"