diff --git a/api/.env.development b/api/.env.development index 7c42ec26ce..3261fcf359 100644 --- a/api/.env.development +++ b/api/.env.development @@ -31,3 +31,4 @@ BYPASS_CORS_CHECKS=true CHOKIDAR_USEPOLLING=true LOG_TRANSPORT=console LOG_LEVEL=trace +ENABLE_NEXT_DOCKER_RELEASE=true diff --git a/api/.gitignore b/api/.gitignore index 3d895b2eb4..77fdfdbeee 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -93,3 +93,6 @@ dev/local-session # local OIDC config for testing - contains secrets dev/configs/oidc.local.json + +# local api keys +dev/keys/* diff --git a/api/docs/developer/feature-flags.md b/api/docs/developer/feature-flags.md new file mode 100644 index 0000000000..53d9425b8c --- /dev/null +++ b/api/docs/developer/feature-flags.md @@ -0,0 +1,247 @@ +# Feature Flags + +Feature flags allow you to conditionally enable or disable functionality in the Unraid API. This is useful for gradually rolling out new features, A/B testing, or keeping experimental code behind flags during development. + +## Setting Up Feature Flags + +### 1. Define the Feature Flag + +Feature flags are defined as environment variables and collected in `src/consts.ts`: + +```typescript +// src/environment.ts +export const ENABLE_MY_NEW_FEATURE = process.env.ENABLE_MY_NEW_FEATURE === 'true'; + +// src/consts.ts +export const FeatureFlags = Object.freeze({ + ENABLE_NEXT_DOCKER_RELEASE, + ENABLE_MY_NEW_FEATURE, // Add your new flag here +}); +``` + +### 2. Set the Environment Variable + +Set the environment variable when running the API: + +```bash +ENABLE_MY_NEW_FEATURE=true unraid-api start +``` + +Or add it to your `.env` file: + +```env +ENABLE_MY_NEW_FEATURE=true +``` + +## Using Feature Flags in GraphQL + +### Method 1: @UseFeatureFlag Decorator (Schema-Level) + +The `@UseFeatureFlag` decorator conditionally includes or excludes GraphQL fields, queries, and mutations from the schema based on feature flags. When a feature flag is disabled, the field won't appear in the GraphQL schema at all. + +```typescript +import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js'; +import { Query, Mutation, ResolveField } from '@nestjs/graphql'; + +@Resolver() +export class MyResolver { + + // Conditionally include a query + @UseFeatureFlag('ENABLE_MY_NEW_FEATURE') + @Query(() => String) + async experimentalQuery() { + return 'This query only exists when ENABLE_MY_NEW_FEATURE is true'; + } + + // Conditionally include a mutation + @UseFeatureFlag('ENABLE_MY_NEW_FEATURE') + @Mutation(() => Boolean) + async experimentalMutation() { + return true; + } + + // Conditionally include a field resolver + @UseFeatureFlag('ENABLE_MY_NEW_FEATURE') + @ResolveField(() => String) + async experimentalField() { + return 'This field only exists when the flag is enabled'; + } +} +``` + +**Benefits:** +- Clean schema - disabled features don't appear in GraphQL introspection +- No runtime overhead for disabled features +- Clear feature boundaries + +**Use when:** +- You want to completely hide features from the GraphQL schema +- The feature is experimental or in beta +- You're doing a gradual rollout + +### Method 2: checkFeatureFlag Function (Runtime) + +The `checkFeatureFlag` function provides runtime feature flag checking within resolver methods. It throws a `ForbiddenException` if the feature is disabled. + +```typescript +import { checkFeatureFlag } from '@app/unraid-api/utils/feature-flag.helper.js'; +import { FeatureFlags } from '@app/consts.js'; +import { Query, ResolveField } from '@nestjs/graphql'; + +@Resolver() +export class MyResolver { + + @Query(() => String) + async myQuery( + @Args('useNewAlgorithm', { nullable: true }) useNewAlgorithm?: boolean + ) { + // Conditionally use new logic based on feature flag + if (useNewAlgorithm) { + checkFeatureFlag(FeatureFlags, 'ENABLE_MY_NEW_FEATURE'); + return this.newAlgorithm(); + } + + return this.oldAlgorithm(); + } + + @ResolveField(() => String) + async dataField() { + // Check flag at the start of the method + checkFeatureFlag(FeatureFlags, 'ENABLE_MY_NEW_FEATURE'); + + // Feature-specific logic here + return this.computeExperimentalData(); + } +} +``` + +**Benefits:** +- More granular control within methods +- Can conditionally execute parts of a method +- Useful for A/B testing scenarios +- Good for gradual migration strategies + +**Use when:** +- You need conditional logic within a method +- The field should exist but behavior changes based on the flag +- You're migrating from old to new implementation gradually + +## Feature Flag Patterns + +### Pattern 1: Complete Feature Toggle + +Hide an entire feature behind a flag: + +```typescript +@UseFeatureFlag('ENABLE_DOCKER_TEMPLATES') +@Resolver(() => DockerTemplate) +export class DockerTemplateResolver { + // All resolvers in this class are toggled by the flag +} +``` + +### Pattern 2: Gradual Migration + +Migrate from old to new implementation: + +```typescript +@Query(() => [Container]) +async getContainers(@Args('version') version?: string) { + if (version === 'v2') { + checkFeatureFlag(FeatureFlags, 'ENABLE_CONTAINERS_V2'); + return this.getContainersV2(); + } + + return this.getContainersV1(); +} +``` + +### Pattern 3: Beta Features + +Mark features as beta: + +```typescript +@UseFeatureFlag('ENABLE_BETA_FEATURES') +@ResolveField(() => BetaMetrics, { + description: 'BETA: Advanced metrics (requires ENABLE_BETA_FEATURES flag)' +}) +async betaMetrics() { + return this.computeBetaMetrics(); +} +``` + +### Pattern 4: Performance Optimizations + +Toggle expensive operations: + +```typescript +@ResolveField(() => Statistics) +async statistics() { + const basicStats = await this.getBasicStats(); + + try { + checkFeatureFlag(FeatureFlags, 'ENABLE_ADVANCED_ANALYTICS'); + const advancedStats = await this.getAdvancedStats(); + return { ...basicStats, ...advancedStats }; + } catch { + // Feature disabled, return only basic stats + return basicStats; + } +} +``` + +## Testing with Feature Flags + +When writing tests for feature-flagged code, create a mock to control feature flag values: + +```typescript +import { vi } from 'vitest'; + +// Mock the entire consts module +vi.mock('@app/consts.js', async () => { + const actual = await vi.importActual('@app/consts.js'); + return { + ...actual, + FeatureFlags: { + ENABLE_MY_NEW_FEATURE: true, // Set your test value + ENABLE_NEXT_DOCKER_RELEASE: false, + } + }; +}); + +describe('MyResolver', () => { + it('should execute new logic when feature is enabled', async () => { + // Test new behavior with mocked flag + }); +}); +``` + +## Best Practices + +1. **Naming Convention**: Use `ENABLE_` prefix for boolean feature flags +2. **Environment Variables**: Always use uppercase with underscores +3. **Documentation**: Document what each feature flag controls +4. **Cleanup**: Remove feature flags once features are stable and fully rolled out +5. **Default State**: New features should default to `false` (disabled) +6. **Granularity**: Keep feature flags focused on a single feature or capability +7. **Testing**: Always test both enabled and disabled states + +## Common Use Cases + +- **Experimental Features**: Hide unstable features in production +- **Gradual Rollouts**: Enable features for specific environments first +- **A/B Testing**: Toggle between different implementations +- **Performance**: Disable expensive operations when not needed +- **Breaking Changes**: Provide migration path with both old and new behavior +- **Debug Features**: Enable additional logging or debugging tools + +## Checking Active Feature Flags + +To see which feature flags are currently active: + +```typescript +// Log all feature flags on startup +console.log('Active Feature Flags:', FeatureFlags); +``` + +Or check via GraphQL introspection to see which fields are available based on current flags. diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 90017c2dd2..581523aef4 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -139,6 +139,9 @@ type ArrayDisk implements Node { """ata | nvme | usb | (others)""" transport: String color: ArrayDiskFsColor + + """Whether the disk is currently spinning""" + isSpinning: Boolean } interface Node { @@ -346,6 +349,9 @@ type Disk implements Node { """The partitions on the disk""" partitions: [DiskPartition!]! + + """Whether the disk is spinning or not""" + isSpinning: Boolean! } """The type of interface the disk uses to connect to the system""" @@ -1044,6 +1050,19 @@ enum ThemeName { white } +type ExplicitStatusItem { + name: String! + updateStatus: UpdateStatus! +} + +"""Update status of a container.""" +enum UpdateStatus { + UP_TO_DATE + UPDATE_AVAILABLE + REBUILD_READY + UNKNOWN +} + type ContainerPort { ip: String privatePort: Port @@ -1083,6 +1102,8 @@ type DockerContainer implements Node { networkSettings: JSON mounts: [JSON!] autoStart: Boolean! + isUpdateAvailable: Boolean + isRebuildReady: Boolean } enum ContainerState { @@ -1113,6 +1134,7 @@ type Docker implements Node { containers(skipCache: Boolean! = false): [DockerContainer!]! networks(skipCache: Boolean! = false): [DockerNetwork!]! organizer: ResolvedOrganizerV1! + containerUpdateStatuses: [ExplicitStatusItem!]! } type ResolvedOrganizerView { @@ -2413,6 +2435,7 @@ type Mutation { setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1! deleteDockerEntries(entryIds: [String!]!): ResolvedOrganizerV1! moveDockerEntriesToFolder(sourceEntryIds: [String!]!, destinationFolderId: String!): ResolvedOrganizerV1! + refreshDockerDigests: Boolean! """Initiates a flash drive backup using a configured remote.""" initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus! diff --git a/api/package.json b/api/package.json index 3569d9adfe..ba82c31b53 100644 --- a/api/package.json +++ b/api/package.json @@ -94,7 +94,7 @@ "command-exists": "1.2.9", "convert": "5.12.0", "cookie": "1.0.2", - "cron": "4.3.3", + "cron": "4.3.0", "cross-fetch": "4.1.0", "diff": "8.0.2", "dockerode": "4.0.7", diff --git a/api/src/consts.ts b/api/src/consts.ts index b4bc015c23..92ff199e93 100644 --- a/api/src/consts.ts +++ b/api/src/consts.ts @@ -2,7 +2,7 @@ import { join } from 'path'; import type { JSONWebKeySet } from 'jose'; -import { PORT } from '@app/environment.js'; +import { ENABLE_NEXT_DOCKER_RELEASE, PORT } from '@app/environment.js'; export const getInternalApiAddress = (isHttp = true, nginxPort = 80) => { const envPort = PORT; @@ -79,3 +79,14 @@ export const KEYSERVER_VALIDATION_ENDPOINT = 'https://keys.lime-technology.com/v /** Set the max retries for the GraphQL Client */ export const MAX_RETRIES_FOR_LINEAR_BACKOFF = 100; + +/** + * Feature flags are used to conditionally enable or disable functionality in the Unraid API. + * + * Keys are human readable feature flag names -- will be used to construct error messages. + * + * Values are boolean/truthy values. + */ +export const FeatureFlags = Object.freeze({ + ENABLE_NEXT_DOCKER_RELEASE, +}); diff --git a/api/src/environment.ts b/api/src/environment.ts index 1aebbefb49..b1d3c2bad3 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -110,3 +110,6 @@ export const PATHS_CONFIG_MODULES = export const PATHS_LOCAL_SESSION_FILE = process.env.PATHS_LOCAL_SESSION_FILE ?? '/var/run/unraid-api/local-session'; + +/** feature flag for the upcoming docker release */ +export const ENABLE_NEXT_DOCKER_RELEASE = process.env.ENABLE_NEXT_DOCKER_RELEASE === 'true'; diff --git a/api/src/unraid-api/app/app.module.ts b/api/src/unraid-api/app/app.module.ts index e8bc4a71b6..8617b0c3d8 100644 --- a/api/src/unraid-api/app/app.module.ts +++ b/api/src/unraid-api/app/app.module.ts @@ -14,6 +14,7 @@ import { AuthModule } from '@app/unraid-api/auth/auth.module.js'; import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js'; import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js'; import { CronModule } from '@app/unraid-api/cron/cron.module.js'; +import { JobModule } from '@app/unraid-api/cron/job.module.js'; import { GraphModule } from '@app/unraid-api/graph/graph.module.js'; import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js'; import { RestModule } from '@app/unraid-api/rest/rest.module.js'; @@ -24,7 +25,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u GlobalDepsModule, LegacyConfigModule, PubSubModule, - ScheduleModule.forRoot(), + JobModule, LoggerModule.forRoot({ pinoHttp: { logger: apiLogger, diff --git a/api/src/unraid-api/cron/cron.module.ts b/api/src/unraid-api/cron/cron.module.ts index 86b0b625f6..bada7aae57 100644 --- a/api/src/unraid-api/cron/cron.module.ts +++ b/api/src/unraid-api/cron/cron.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; -import { ScheduleModule } from '@nestjs/schedule'; +import { JobModule } from '@app/unraid-api/cron/job.module.js'; import { LogRotateService } from '@app/unraid-api/cron/log-rotate.service.js'; import { WriteFlashFileService } from '@app/unraid-api/cron/write-flash-file.service.js'; @Module({ - imports: [], + imports: [JobModule], providers: [WriteFlashFileService, LogRotateService], }) export class CronModule {} diff --git a/api/src/unraid-api/cron/job.module.ts b/api/src/unraid-api/cron/job.module.ts new file mode 100644 index 0000000000..22dc8fd9e5 --- /dev/null +++ b/api/src/unraid-api/cron/job.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; + +/** + * Sets up common dependencies for initializing jobs (e.g. scheduler registry, cron jobs). + * + * Simplifies testing setup & application dependency tree by ensuring `forRoot` is called only once. + */ +@Module({ + imports: [ScheduleModule.forRoot()], + exports: [ScheduleModule], +}) +export class JobModule {} diff --git a/api/src/unraid-api/decorators/omit-if.decorator.spec.ts b/api/src/unraid-api/decorators/omit-if.decorator.spec.ts new file mode 100644 index 0000000000..eb936390ce --- /dev/null +++ b/api/src/unraid-api/decorators/omit-if.decorator.spec.ts @@ -0,0 +1,172 @@ +import { Reflector } from '@nestjs/core'; +import { Field, Mutation, ObjectType, Query, ResolveField, Resolver } from '@nestjs/graphql'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OMIT_IF_METADATA_KEY, OmitIf } from '@app/unraid-api/decorators/omit-if.decorator.js'; + +describe('OmitIf Decorator', () => { + let reflector: Reflector; + + beforeEach(() => { + reflector = new Reflector(); + }); + + describe('OmitIf', () => { + it('should set metadata when condition is true', () => { + class TestResolver { + @OmitIf(true) + testMethod() { + return 'test'; + } + } + + const instance = new TestResolver(); + const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod); + expect(metadata).toBe(true); + }); + + it('should not set metadata when condition is false', () => { + class TestResolver { + @OmitIf(false) + testMethod() { + return 'test'; + } + } + + const instance = new TestResolver(); + const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod); + expect(metadata).toBeUndefined(); + }); + + it('should evaluate function conditions', () => { + const mockCondition = vi.fn(() => true); + + class TestResolver { + @OmitIf(mockCondition) + testMethod() { + return 'test'; + } + } + + expect(mockCondition).toHaveBeenCalledOnce(); + const instance = new TestResolver(); + const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod); + expect(metadata).toBe(true); + }); + + it('should evaluate function conditions that return false', () => { + const mockCondition = vi.fn(() => false); + + class TestResolver { + @OmitIf(mockCondition) + testMethod() { + return 'test'; + } + } + + expect(mockCondition).toHaveBeenCalledOnce(); + const instance = new TestResolver(); + const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod); + expect(metadata).toBeUndefined(); + }); + + it('should work with environment variables', () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + class TestResolver { + @OmitIf(process.env.NODE_ENV === 'production') + testMethod() { + return 'test'; + } + } + + const instance = new TestResolver(); + const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod); + expect(metadata).toBe(true); + + process.env.NODE_ENV = originalEnv; + }); + }); + + describe('Integration with NestJS GraphQL decorators', () => { + it('should work with @Query decorator', () => { + @Resolver() + class TestResolver { + @OmitIf(true) + @Query(() => String) + omittedQuery() { + return 'test'; + } + + @OmitIf(false) + @Query(() => String) + includedQuery() { + return 'test'; + } + } + + const instance = new TestResolver(); + const omittedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.omittedQuery); + const includedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.includedQuery); + + expect(omittedMetadata).toBe(true); + expect(includedMetadata).toBeUndefined(); + }); + + it('should work with @Mutation decorator', () => { + @Resolver() + class TestResolver { + @OmitIf(true) + @Mutation(() => String) + omittedMutation() { + return 'test'; + } + + @OmitIf(false) + @Mutation(() => String) + includedMutation() { + return 'test'; + } + } + + const instance = new TestResolver(); + const omittedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.omittedMutation); + const includedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.includedMutation); + + expect(omittedMetadata).toBe(true); + expect(includedMetadata).toBeUndefined(); + }); + + it('should work with @ResolveField decorator', () => { + @ObjectType() + class TestType { + @Field() + id: string = ''; + } + + @Resolver(() => TestType) + class TestResolver { + @OmitIf(true) + @ResolveField(() => String) + omittedField() { + return 'test'; + } + + @OmitIf(false) + @ResolveField(() => String) + includedField() { + return 'test'; + } + } + + const instance = new TestResolver(); + const omittedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.omittedField); + const includedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.includedField); + + expect(omittedMetadata).toBe(true); + expect(includedMetadata).toBeUndefined(); + }); + }); +}); diff --git a/api/src/unraid-api/decorators/omit-if.decorator.ts b/api/src/unraid-api/decorators/omit-if.decorator.ts new file mode 100644 index 0000000000..3fc0cf345f --- /dev/null +++ b/api/src/unraid-api/decorators/omit-if.decorator.ts @@ -0,0 +1,80 @@ +import { SetMetadata } from '@nestjs/common'; +import { Extensions } from '@nestjs/graphql'; + +import { MapperKind, mapSchema } from '@graphql-tools/utils'; +import { GraphQLFieldConfig, GraphQLSchema } from 'graphql'; + +export const OMIT_IF_METADATA_KEY = 'omitIf'; + +/** + * Decorator that conditionally omits a GraphQL field/query/mutation based on a condition. + * The field will only be omitted from the schema when the condition evaluates to true. + * + * @param condition - If the condition evaluates to true, the field will be omitted from the schema + * @returns A decorator that wraps the target field/query/mutation + * + * @example + * ```typescript + * @OmitIf(process.env.NODE_ENV === 'production') + * @Query(() => String) + * async debugQuery() { + * return 'This query is omitted in production'; + * } + * ``` + */ +export function OmitIf(condition: boolean | (() => boolean)): MethodDecorator & PropertyDecorator { + const shouldOmit = typeof condition === 'function' ? condition() : condition; + + return (target: object, propertyKey?: string | symbol, descriptor?: PropertyDescriptor) => { + if (shouldOmit) { + SetMetadata(OMIT_IF_METADATA_KEY, true)( + target, + propertyKey as string, + descriptor as PropertyDescriptor + ); + Extensions({ omitIf: true })( + target, + propertyKey as string, + descriptor as PropertyDescriptor + ); + } + + return descriptor; + }; +} + +/** + * Schema transformer that omits fields/queries/mutations based on the OmitIf decorator. + * @param schema - The GraphQL schema to transform + * @returns The transformed GraphQL schema + */ +export function omitIfSchemaTransformer(schema: GraphQLSchema): GraphQLSchema { + return mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: ( + fieldConfig: GraphQLFieldConfig, + fieldName: string, + typeName: string + ) => { + const extensions = fieldConfig.extensions || {}; + + if (extensions.omitIf === true) { + return null; + } + + return fieldConfig; + }, + [MapperKind.ROOT_FIELD]: ( + fieldConfig: GraphQLFieldConfig, + fieldName: string, + typeName: string + ) => { + const extensions = fieldConfig.extensions || {}; + + if (extensions.omitIf === true) { + return null; + } + + return fieldConfig; + }, + }); +} diff --git a/api/src/unraid-api/decorators/use-feature-flag.decorator.spec.ts b/api/src/unraid-api/decorators/use-feature-flag.decorator.spec.ts new file mode 100644 index 0000000000..5143652a79 --- /dev/null +++ b/api/src/unraid-api/decorators/use-feature-flag.decorator.spec.ts @@ -0,0 +1,317 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +// fixme: types don't sync with mocks, and there's no override to simplify testing. + +import { Reflector } from '@nestjs/core'; +import { Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OMIT_IF_METADATA_KEY } from '@app/unraid-api/decorators/omit-if.decorator.js'; +import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js'; + +// Mock the FeatureFlags +vi.mock('@app/consts.js', () => ({ + FeatureFlags: Object.freeze({ + ENABLE_NEXT_DOCKER_RELEASE: false, + ENABLE_EXPERIMENTAL_FEATURE: true, + ENABLE_DEBUG_MODE: false, + ENABLE_BETA_FEATURES: true, + }), +})); + +describe('UseFeatureFlag Decorator', () => { + let reflector: Reflector; + + beforeEach(() => { + reflector = new Reflector(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Basic functionality', () => { + it('should omit field when feature flag is false', () => { + @Resolver() + class TestResolver { + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @Query(() => String) + testQuery() { + return 'test'; + } + } + + const instance = new TestResolver(); + const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testQuery); + expect(metadata).toBe(true); // Should be omitted because flag is false + }); + + it('should include field when feature flag is true', () => { + @Resolver() + class TestResolver { + @UseFeatureFlag('ENABLE_EXPERIMENTAL_FEATURE') + @Query(() => String) + testQuery() { + return 'test'; + } + } + + const instance = new TestResolver(); + const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testQuery); + expect(metadata).toBeUndefined(); // Should not be omitted because flag is true + }); + }); + + describe('With different decorator types', () => { + it('should work with @Query decorator', () => { + @Resolver() + class TestResolver { + @UseFeatureFlag('ENABLE_DEBUG_MODE') + @Query(() => String) + debugQuery() { + return 'debug'; + } + + @UseFeatureFlag('ENABLE_BETA_FEATURES') + @Query(() => String) + betaQuery() { + return 'beta'; + } + } + + const instance = new TestResolver(); + const debugMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.debugQuery); + const betaMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.betaQuery); + + expect(debugMetadata).toBe(true); // ENABLE_DEBUG_MODE is false + expect(betaMetadata).toBeUndefined(); // ENABLE_BETA_FEATURES is true + }); + + it('should work with @Mutation decorator', () => { + @Resolver() + class TestResolver { + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @Mutation(() => String) + dockerMutation() { + return 'docker'; + } + + @UseFeatureFlag('ENABLE_EXPERIMENTAL_FEATURE') + @Mutation(() => String) + experimentalMutation() { + return 'experimental'; + } + } + + const instance = new TestResolver(); + const dockerMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.dockerMutation); + const experimentalMetadata = reflector.get( + OMIT_IF_METADATA_KEY, + instance.experimentalMutation + ); + + expect(dockerMetadata).toBe(true); // ENABLE_NEXT_DOCKER_RELEASE is false + expect(experimentalMetadata).toBeUndefined(); // ENABLE_EXPERIMENTAL_FEATURE is true + }); + + it('should work with @ResolveField decorator', () => { + @Resolver() + class TestResolver { + @UseFeatureFlag('ENABLE_DEBUG_MODE') + @ResolveField(() => String) + debugField() { + return 'debug'; + } + + @UseFeatureFlag('ENABLE_BETA_FEATURES') + @ResolveField(() => String) + betaField() { + return 'beta'; + } + } + + const instance = new TestResolver(); + const debugMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.debugField); + const betaMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.betaField); + + expect(debugMetadata).toBe(true); // ENABLE_DEBUG_MODE is false + expect(betaMetadata).toBeUndefined(); // ENABLE_BETA_FEATURES is true + }); + }); + + describe('Multiple decorators on same class', () => { + it('should handle multiple feature flags independently', () => { + @Resolver() + class TestResolver { + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @Query(() => String) + dockerQuery() { + return 'docker'; + } + + @UseFeatureFlag('ENABLE_EXPERIMENTAL_FEATURE') + @Query(() => String) + experimentalQuery() { + return 'experimental'; + } + + @UseFeatureFlag('ENABLE_DEBUG_MODE') + @Query(() => String) + debugQuery() { + return 'debug'; + } + + @UseFeatureFlag('ENABLE_BETA_FEATURES') + @Query(() => String) + betaQuery() { + return 'beta'; + } + } + + const instance = new TestResolver(); + + expect(reflector.get(OMIT_IF_METADATA_KEY, instance.dockerQuery)).toBe(true); + expect(reflector.get(OMIT_IF_METADATA_KEY, instance.experimentalQuery)).toBeUndefined(); + expect(reflector.get(OMIT_IF_METADATA_KEY, instance.debugQuery)).toBe(true); + expect(reflector.get(OMIT_IF_METADATA_KEY, instance.betaQuery)).toBeUndefined(); + }); + }); + + describe('Type safety', () => { + it('should only accept valid feature flag keys', () => { + // This test verifies TypeScript compile-time type safety + // The following would cause a TypeScript error if uncommented: + // @UseFeatureFlag('INVALID_FLAG') + + @Resolver() + class TestResolver { + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @Query(() => String) + validQuery() { + return 'valid'; + } + } + + const instance = new TestResolver(); + expect(instance.validQuery).toBeDefined(); + }); + }); + + describe('Integration scenarios', () => { + it('should work correctly with other decorators', () => { + const customDecorator = ( + target: any, + propertyKey: string | symbol, + descriptor: PropertyDescriptor + ) => { + Reflect.defineMetadata('custom', true, target, propertyKey); + return descriptor; + }; + + @Resolver() + class TestResolver { + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @customDecorator + @Query(() => String) + multiDecoratorQuery() { + return 'multi'; + } + } + + const instance = new TestResolver(); + const omitMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.multiDecoratorQuery); + const customMetadata = Reflect.getMetadata('custom', instance, 'multiDecoratorQuery'); + + expect(omitMetadata).toBe(true); + expect(customMetadata).toBe(true); + }); + + it('should maintain correct decorator order', () => { + const orderTracker: string[] = []; + + const trackingDecorator = (name: string) => { + return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + orderTracker.push(name); + return descriptor; + }; + }; + + @Resolver() + class TestResolver { + @trackingDecorator('first') + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @trackingDecorator('last') + @Query(() => String) + orderedQuery() { + return 'ordered'; + } + } + + // Decorators are applied bottom-up + expect(orderTracker).toEqual(['last', 'first']); + }); + }); + + describe('Real-world usage patterns', () => { + it('should work with Docker resolver pattern', () => { + @Resolver() + class DockerResolver { + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @Mutation(() => String) + async createDockerFolder(name: string) { + return `Created folder: ${name}`; + } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @Mutation(() => String) + async deleteDockerEntries(entryIds: string[]) { + return `Deleted entries: ${entryIds.join(', ')}`; + } + + @Query(() => String) + async getDockerInfo() { + return 'Docker info'; + } + } + + const instance = new DockerResolver(); + + // Feature flag is false, so these should be omitted + expect(reflector.get(OMIT_IF_METADATA_KEY, instance.createDockerFolder)).toBe(true); + expect(reflector.get(OMIT_IF_METADATA_KEY, instance.deleteDockerEntries)).toBe(true); + + // No feature flag, so this should not be omitted + expect(reflector.get(OMIT_IF_METADATA_KEY, instance.getDockerInfo)).toBeUndefined(); + }); + + it('should handle mixed feature flags in same resolver', () => { + @Resolver() + class MixedResolver { + @UseFeatureFlag('ENABLE_EXPERIMENTAL_FEATURE') + @Query(() => String) + experimentalQuery() { + return 'experimental'; + } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @Query(() => String) + dockerQuery() { + return 'docker'; + } + + @UseFeatureFlag('ENABLE_BETA_FEATURES') + @Mutation(() => String) + betaMutation() { + return 'beta'; + } + } + + const instance = new MixedResolver(); + + expect(reflector.get(OMIT_IF_METADATA_KEY, instance.experimentalQuery)).toBeUndefined(); + expect(reflector.get(OMIT_IF_METADATA_KEY, instance.dockerQuery)).toBe(true); + expect(reflector.get(OMIT_IF_METADATA_KEY, instance.betaMutation)).toBeUndefined(); + }); + }); +}); diff --git a/api/src/unraid-api/decorators/use-feature-flag.decorator.ts b/api/src/unraid-api/decorators/use-feature-flag.decorator.ts new file mode 100644 index 0000000000..96910bfe5a --- /dev/null +++ b/api/src/unraid-api/decorators/use-feature-flag.decorator.ts @@ -0,0 +1,22 @@ +import { FeatureFlags } from '@app/consts.js'; +import { OmitIf } from '@app/unraid-api/decorators/omit-if.decorator.js'; + +/** + * Decorator that conditionally includes a GraphQL field/query/mutation based on a feature flag. + * The field will only be included in the schema when the feature flag is enabled. + * + * @param flagKey - The key of the feature flag in FeatureFlags + * @returns A decorator that wraps OmitIf + * + * @example + * ```typescript + * @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + * @Mutation(() => String) + * async experimentalMutation() { + * return 'This mutation is only available when ENABLE_NEXT_DOCKER_RELEASE is true'; + * } + * ``` + */ +export function UseFeatureFlag(flagKey: keyof typeof FeatureFlags): MethodDecorator & PropertyDecorator { + return OmitIf(!FeatureFlags[flagKey]); +} diff --git a/api/src/unraid-api/graph/graph.module.ts b/api/src/unraid-api/graph/graph.module.ts index 276610f16e..9cd9368810 100644 --- a/api/src/unraid-api/graph/graph.module.ts +++ b/api/src/unraid-api/graph/graph.module.ts @@ -12,6 +12,7 @@ import { NoUnusedVariablesRule } from 'graphql'; import { ENVIRONMENT } from '@app/environment.js'; import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js'; +import { omitIfSchemaTransformer } from '@app/unraid-api/decorators/omit-if.decorator.js'; // Import enum registrations to ensure they're registered with GraphQL import '@app/unraid-api/graph/auth/auth-action.enum.js'; @@ -64,7 +65,12 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js'; }, // Only add transform when not in test environment to avoid GraphQL version conflicts transformSchema: - process.env.NODE_ENV === 'test' ? undefined : usePermissionsSchemaTransformer, + process.env.NODE_ENV === 'test' + ? undefined + : (schema) => { + const schemaWithPermissions = usePermissionsSchemaTransformer(schema); + return omitIfSchemaTransformer(schemaWithPermissions); + }, validationRules: [NoUnusedVariablesRule], }; }, diff --git a/api/src/unraid-api/graph/resolvers/docker/container-status.job.ts b/api/src/unraid-api/graph/resolvers/docker/container-status.job.ts new file mode 100644 index 0000000000..2131585d49 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/container-status.job.ts @@ -0,0 +1,47 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { SchedulerRegistry, Timeout } from '@nestjs/schedule'; + +import { CronJob } from 'cron'; + +import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; +import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js'; + +@Injectable() +export class ContainerStatusJob implements OnApplicationBootstrap { + private readonly logger = new Logger(ContainerStatusJob.name); + constructor( + private readonly dockerManifestService: DockerManifestService, + private readonly schedulerRegistry: SchedulerRegistry, + private readonly dockerConfigService: DockerConfigService + ) {} + + /** + * Initialize cron job for refreshing the update status for all containers on a user-configurable schedule. + */ + onApplicationBootstrap() { + if (!this.dockerConfigService.enabled()) return; + const cronExpression = this.dockerConfigService.getConfig().updateCheckCronSchedule; + const cronJob = CronJob.from({ + cronTime: cronExpression, + onTick: () => { + this.dockerManifestService.refreshDigests().catch((error) => { + this.logger.warn(error, 'Failed to refresh container update status'); + }); + }, + start: true, + }); + this.schedulerRegistry.addCronJob(ContainerStatusJob.name, cronJob); + this.logger.verbose( + `Initialized cron job for refreshing container update status: ${ContainerStatusJob.name}` + ); + } + + /** + * Refresh container digests 5 seconds after application start. + */ + @Timeout(5_000) + async refreshContainerDigestsAfterStartup() { + if (!this.dockerConfigService.enabled()) return; + await this.dockerManifestService.refreshDigests(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-config.model.ts b/api/src/unraid-api/graph/resolvers/docker/docker-config.model.ts new file mode 100644 index 0000000000..e7a47ae660 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-config.model.ts @@ -0,0 +1,7 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class DockerConfig { + @Field(() => String) + updateCheckCronSchedule!: string; +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-config.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker-config.service.spec.ts new file mode 100644 index 0000000000..cad15ceae0 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-config.service.spec.ts @@ -0,0 +1,195 @@ +import { ConfigService } from '@nestjs/config'; +import { CronExpression } from '@nestjs/schedule'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { ValidationError } from 'class-validator'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AppError } from '@app/core/errors/app-error.js'; +import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; + +vi.mock('cron', () => ({ + validateCronExpression: vi.fn(), +})); + +vi.mock('@app/unraid-api/graph/resolvers/validation.utils.js', () => ({ + validateObject: vi.fn(), +})); + +describe('DockerConfigService - validate', () => { + let service: DockerConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DockerConfigService, + { + provide: ConfigService, + useValue: { + get: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(DockerConfigService); + vi.clearAllMocks(); + }); + + describe('validate', () => { + it('should validate and return docker config for valid cron expression', async () => { + const inputConfig = { updateCheckCronSchedule: '0 6 * * *' }; + const validatedConfig = { updateCheckCronSchedule: '0 6 * * *' }; + + const { validateObject } = await import( + '@app/unraid-api/graph/resolvers/validation.utils.js' + ); + const { validateCronExpression } = await import('cron'); + + vi.mocked(validateObject).mockResolvedValue(validatedConfig); + vi.mocked(validateCronExpression).mockReturnValue({ valid: true }); + + const result = await service.validate(inputConfig); + + expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig); + expect(validateCronExpression).toHaveBeenCalledWith('0 6 * * *'); + expect(result).toBe(validatedConfig); + }); + + it('should validate and return docker config for predefined cron expression', async () => { + const inputConfig = { updateCheckCronSchedule: CronExpression.EVERY_DAY_AT_6AM }; + const validatedConfig = { updateCheckCronSchedule: CronExpression.EVERY_DAY_AT_6AM }; + + const { validateObject } = await import( + '@app/unraid-api/graph/resolvers/validation.utils.js' + ); + const { validateCronExpression } = await import('cron'); + + vi.mocked(validateObject).mockResolvedValue(validatedConfig); + vi.mocked(validateCronExpression).mockReturnValue({ valid: true }); + + const result = await service.validate(inputConfig); + + expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig); + expect(validateCronExpression).toHaveBeenCalledWith(CronExpression.EVERY_DAY_AT_6AM); + expect(result).toBe(validatedConfig); + }); + + it('should throw AppError for invalid cron expression', async () => { + const inputConfig = { updateCheckCronSchedule: 'invalid-cron' }; + const validatedConfig = { updateCheckCronSchedule: 'invalid-cron' }; + + const { validateObject } = await import( + '@app/unraid-api/graph/resolvers/validation.utils.js' + ); + const { validateCronExpression } = await import('cron'); + + vi.mocked(validateObject).mockResolvedValue(validatedConfig); + vi.mocked(validateCronExpression).mockReturnValue({ valid: false }); + + await expect(service.validate(inputConfig)).rejects.toThrow( + new AppError('Cron expression not supported: invalid-cron') + ); + + expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig); + expect(validateCronExpression).toHaveBeenCalledWith('invalid-cron'); + }); + + it('should throw AppError for empty cron expression', async () => { + const inputConfig = { updateCheckCronSchedule: '' }; + const validatedConfig = { updateCheckCronSchedule: '' }; + + const { validateObject } = await import( + '@app/unraid-api/graph/resolvers/validation.utils.js' + ); + const { validateCronExpression } = await import('cron'); + + vi.mocked(validateObject).mockResolvedValue(validatedConfig); + vi.mocked(validateCronExpression).mockReturnValue({ valid: false }); + + await expect(service.validate(inputConfig)).rejects.toThrow( + new AppError('Cron expression not supported: ') + ); + + expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig); + expect(validateCronExpression).toHaveBeenCalledWith(''); + }); + + it('should throw AppError for malformed cron expression', async () => { + const inputConfig = { updateCheckCronSchedule: '* * * *' }; + const validatedConfig = { updateCheckCronSchedule: '* * * *' }; + + const { validateObject } = await import( + '@app/unraid-api/graph/resolvers/validation.utils.js' + ); + const { validateCronExpression } = await import('cron'); + + vi.mocked(validateObject).mockResolvedValue(validatedConfig); + vi.mocked(validateCronExpression).mockReturnValue({ valid: false }); + + await expect(service.validate(inputConfig)).rejects.toThrow( + new AppError('Cron expression not supported: * * * *') + ); + + expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig); + expect(validateCronExpression).toHaveBeenCalledWith('* * * *'); + }); + + it('should propagate validation errors from validateObject', async () => { + const inputConfig = { updateCheckCronSchedule: '0 6 * * *' }; + const validationError = new ValidationError(); + validationError.property = 'updateCheckCronSchedule'; + + const { validateObject } = await import( + '@app/unraid-api/graph/resolvers/validation.utils.js' + ); + + vi.mocked(validateObject).mockRejectedValue(validationError); + + await expect(service.validate(inputConfig)).rejects.toThrow(); + + expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig); + }); + + it('should handle complex valid cron expressions', async () => { + const inputConfig = { updateCheckCronSchedule: '0 0,12 * * 1-5' }; + const validatedConfig = { updateCheckCronSchedule: '0 0,12 * * 1-5' }; + + const { validateObject } = await import( + '@app/unraid-api/graph/resolvers/validation.utils.js' + ); + const { validateCronExpression } = await import('cron'); + + vi.mocked(validateObject).mockResolvedValue(validatedConfig); + vi.mocked(validateCronExpression).mockReturnValue({ valid: true }); + + const result = await service.validate(inputConfig); + + expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig); + expect(validateCronExpression).toHaveBeenCalledWith('0 0,12 * * 1-5'); + expect(result).toBe(validatedConfig); + }); + + it('should handle input with extra properties', async () => { + const inputConfig = { + updateCheckCronSchedule: '0 6 * * *', + extraProperty: 'should be ignored', + }; + const validatedConfig = { updateCheckCronSchedule: '0 6 * * *' }; + + const { validateObject } = await import( + '@app/unraid-api/graph/resolvers/validation.utils.js' + ); + const { validateCronExpression } = await import('cron'); + + vi.mocked(validateObject).mockResolvedValue(validatedConfig); + vi.mocked(validateCronExpression).mockReturnValue({ valid: true }); + + const result = await service.validate(inputConfig); + + expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig); + expect(validateCronExpression).toHaveBeenCalledWith('0 6 * * *'); + expect(result).toBe(validatedConfig); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts index 402f3a81fb..1ed27212f8 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts @@ -1,59 +1,45 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { CronExpression } from '@nestjs/schedule'; import { ConfigFilePersister } from '@unraid/shared/services/config-file.js'; +import { validateCronExpression } from 'cron'; +import { FeatureFlags } from '@app/consts.js'; import { AppError } from '@app/core/errors/app-error.js'; +import { DockerConfig } from '@app/unraid-api/graph/resolvers/docker/docker-config.model.js'; import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js'; -import { - DEFAULT_ORGANIZER_ROOT_ID, - DEFAULT_ORGANIZER_VIEW_ID, -} from '@app/unraid-api/organizer/organizer.js'; -import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js'; -import { validateOrganizerIntegrity } from '@app/unraid-api/organizer/organizer.validation.js'; @Injectable() -export class DockerConfigService extends ConfigFilePersister { +export class DockerConfigService extends ConfigFilePersister { constructor(configService: ConfigService) { super(configService); } + enabled(): boolean { + return FeatureFlags.ENABLE_NEXT_DOCKER_RELEASE; + } + configKey(): string { - return 'dockerOrganizer'; + return 'docker'; } fileName(): string { - return 'docker.organizer.json'; + return 'docker.config.json'; } - defaultConfig(): OrganizerV1 { + defaultConfig(): DockerConfig { return { - version: 1, - resources: {}, - views: { - default: { - id: DEFAULT_ORGANIZER_VIEW_ID, - name: 'Default', - root: DEFAULT_ORGANIZER_ROOT_ID, - entries: { - root: { - type: 'folder', - id: DEFAULT_ORGANIZER_ROOT_ID, - name: 'Root', - children: [], - }, - }, - }, - }, + updateCheckCronSchedule: CronExpression.EVERY_DAY_AT_6AM, }; } - async validate(config: object): Promise { - const organizer = await validateObject(OrganizerV1, config); - const { isValid, errors } = await validateOrganizerIntegrity(organizer); - if (!isValid) { - throw new AppError(`Docker organizer validation failed: ${JSON.stringify(errors, null, 2)}`); + async validate(config: object): Promise { + const dockerConfig = await validateObject(DockerConfig, config); + const cronExpression = validateCronExpression(dockerConfig.updateCheckCronSchedule); + if (!cronExpression.valid) { + throw new AppError(`Cron expression not supported: ${dockerConfig.updateCheckCronSchedule}`); } - return organizer; + return dockerConfig; } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-container.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker-container.resolver.ts new file mode 100644 index 0000000000..4528b24658 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-container.resolver.ts @@ -0,0 +1,51 @@ +import { Logger } from '@nestjs/common'; +import { Mutation, Parent, ResolveField, Resolver } from '@nestjs/graphql'; + +import { Resource } from '@unraid/shared/graphql.model.js'; +import { AuthAction, UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import { AppError } from '@app/core/errors/app-error.js'; +import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js'; +import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js'; +import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; + +@Resolver(() => DockerContainer) +export class DockerContainerResolver { + private readonly logger = new Logger(DockerContainerResolver.name); + constructor(private readonly dockerManifestService: DockerManifestService) {} + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DOCKER, + }) + @ResolveField(() => Boolean, { nullable: true }) + public async isUpdateAvailable(@Parent() container: DockerContainer) { + try { + return await this.dockerManifestService.isUpdateAvailableCached(container.image); + } catch (error) { + this.logger.error(error); + throw new AppError('Failed to read cached update status. See graphql-api.log for details.'); + } + } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DOCKER, + }) + @ResolveField(() => Boolean, { nullable: true }) + public async isRebuildReady(@Parent() container: DockerContainer) { + return this.dockerManifestService.isRebuildReady(container.hostConfig?.networkMode); + } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + @Mutation(() => Boolean) + public async refreshDockerDigests() { + return this.dockerManifestService.refreshDigests(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-manifest.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-manifest.service.ts new file mode 100644 index 0000000000..b14fe8606b --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-manifest.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; + +import { AsyncMutex } from '@unraid/shared/util/processing.js'; + +import { docker } from '@app/core/utils/index.js'; +import { + CachedStatusEntry, + DockerPhpService, +} from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js'; + +@Injectable() +export class DockerManifestService { + constructor(private readonly dockerPhpService: DockerPhpService) {} + + private readonly refreshDigestsMutex = new AsyncMutex(() => { + return this.dockerPhpService.refreshDigestsViaPhp(); + }); + + /** + * Recomputes local/remote docker container digests and writes them to /var/lib/docker/unraid-update-status.json + * @param mutex - Optional mutex to use for the operation. If not provided, a default mutex will be used. + * @param dockerUpdatePath - Optional path to the DockerUpdate.php file. If not provided, the default path will be used. + * @returns True if the digests were refreshed, false if the operation failed + */ + async refreshDigests(mutex = this.refreshDigestsMutex, dockerUpdatePath?: string) { + return mutex.do(() => { + return this.dockerPhpService.refreshDigestsViaPhp(dockerUpdatePath); + }); + } + + /** + * Checks if an update is available for a given container image. + * @param imageRef - The image reference to check, e.g. "unraid/baseimage:latest". If no tag is provided, "latest" is assumed, following the webgui's implementation. + * @param cacheData read from /var/lib/docker/unraid-update-status.json by default + * @returns True if an update is available, false if not, or null if the status is unknown + */ + async isUpdateAvailableCached(imageRef: string, cacheData?: Record) { + let taggedRef = imageRef; + if (!taggedRef.includes(':')) taggedRef += ':latest'; + + cacheData ??= await this.dockerPhpService.readCachedUpdateStatus(); + const containerData = cacheData[taggedRef]; + if (!containerData) return null; + return containerData.status?.toLowerCase() === 'true'; + } + + /** + * Checks if a container is rebuild ready. + * @param networkMode - The network mode of the container, e.g. "container:unraid/baseimage:latest". + * @returns True if the container is rebuild ready, false if not + */ + async isRebuildReady(networkMode?: string) { + if (!networkMode || !networkMode.startsWith('container:')) return false; + const target = networkMode.slice('container:'.length); + try { + await docker.getContainer(target).inspect(); + return false; + } catch { + return true; // unresolved target -> ':???' equivalent + } + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-php.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-php.service.ts new file mode 100644 index 0000000000..46edc3f6da --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-php.service.ts @@ -0,0 +1,130 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { readFile } from 'fs/promises'; + +import { z } from 'zod'; + +import { phpLoader } from '@app/core/utils/plugins/php-loader.js'; +import { + ExplicitStatusItem, + UpdateStatus, +} from '@app/unraid-api/graph/resolvers/docker/docker-update-status.model.js'; +import { parseDockerPushCalls } from '@app/unraid-api/graph/resolvers/docker/utils/docker-push-parser.js'; + +type StatusItem = { name: string; updateStatus: 0 | 1 | 2 | 3 }; + +/** + * These types reflect the structure of the /var/lib/docker/unraid-update-status.json file, + * which is not controlled by the Unraid API. + */ +const CachedStatusEntrySchema = z.object({ + /** sha256 digest - "sha256:..." */ + local: z.string(), + /** sha256 digest - "sha256:..." */ + remote: z.string(), + /** whether update is available (true), not available (false), or unknown (null) */ + status: z.enum(['true', 'false']).nullable(), +}); +const CachedStatusSchema = z.record(z.string(), CachedStatusEntrySchema); +export type CachedStatusEntry = z.infer; + +@Injectable() +export class DockerPhpService { + private readonly logger = new Logger(DockerPhpService.name); + constructor() {} + + /** + * Reads JSON from a file containing cached update status. + * If the file does not exist, an empty object is returned. + * @param cacheFile + * @returns + */ + async readCachedUpdateStatus( + cacheFile = '/var/lib/docker/unraid-update-status.json' + ): Promise> { + try { + const cache = await readFile(cacheFile, 'utf8'); + const cacheData = JSON.parse(cache); + const { success, data } = CachedStatusSchema.safeParse(cacheData); + if (success) return data; + this.logger.warn(cacheData, 'Invalid cached update status'); + return {}; + } catch (error) { + this.logger.warn(error, 'Failed to read cached update status'); + return {}; + } + } + + /**---------------------- + * Refresh Container Digests + *------------------------**/ + + /** + * Recomputes local/remote digests by triggering `DockerTemplates->getAllInfo(true)` via DockerUpdate.php + * @param dockerUpdatePath - Path to the DockerUpdate.php file + * @returns True if the digests were refreshed, false if the file is not found or the operation failed + */ + async refreshDigestsViaPhp( + dockerUpdatePath = '/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerUpdate.php' + ) { + try { + await phpLoader({ + file: dockerUpdatePath, + method: 'GET', + }); + return true; + } catch { + // ignore; offline may keep remote as 'undef' + return false; + } + } + + /**---------------------- + * Parse Container Statuses + *------------------------**/ + + private parseStatusesFromDockerPush(js: string): ExplicitStatusItem[] { + const matches = parseDockerPushCalls(js); + return matches.map(({ name, updateStatus }) => ({ + name, + updateStatus: this.updateStatusToString(updateStatus as StatusItem['updateStatus']), + })); + } + + private updateStatusToString(updateStatus: 0): UpdateStatus.UP_TO_DATE; + private updateStatusToString(updateStatus: 1): UpdateStatus.UPDATE_AVAILABLE; + private updateStatusToString(updateStatus: 2): UpdateStatus.REBUILD_READY; + private updateStatusToString(updateStatus: 3): UpdateStatus.UNKNOWN; + // prettier-ignore + private updateStatusToString(updateStatus: StatusItem['updateStatus']): ExplicitStatusItem['updateStatus']; + private updateStatusToString( + updateStatus: StatusItem['updateStatus'] + ): ExplicitStatusItem['updateStatus'] { + switch (updateStatus) { + case 0: + return UpdateStatus.UP_TO_DATE; + case 1: + return UpdateStatus.UPDATE_AVAILABLE; + case 2: + return UpdateStatus.REBUILD_READY; + default: + return UpdateStatus.UNKNOWN; + } + } + + /** + * Gets the update statuses for all containers by triggering `DockerTemplates->getAllInfo(true)` via DockerContainers.php + * @param dockerContainersPath - Path to the DockerContainers.php file + * @returns The update statuses for all containers + */ + async getContainerUpdateStatuses( + dockerContainersPath = '/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php' + ): Promise { + const stdout = await phpLoader({ + file: dockerContainersPath, + method: 'GET', + }); + const parts = stdout.split('\0'); // [html, "docker.push(...)", busyFlag] + const js = parts[1] || ''; + return this.parseStatusesFromDockerPush(js); + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-update-status.model.ts b/api/src/unraid-api/graph/resolvers/docker/docker-update-status.model.ts new file mode 100644 index 0000000000..a6276edd15 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-update-status.model.ts @@ -0,0 +1,25 @@ +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; + +/** + * Note that these values propagate down to API consumers, so be aware of breaking changes. + */ +export enum UpdateStatus { + UP_TO_DATE = 'UP_TO_DATE', + UPDATE_AVAILABLE = 'UPDATE_AVAILABLE', + REBUILD_READY = 'REBUILD_READY', + UNKNOWN = 'UNKNOWN', +} + +registerEnumType(UpdateStatus, { + name: 'UpdateStatus', + description: 'Update status of a container.', +}); + +@ObjectType() +export class ExplicitStatusItem { + @Field(() => String) + name!: string; + + @Field(() => UpdateStatus) + updateStatus!: UpdateStatus; +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.module.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.module.spec.ts index 97e3b28e49..af5500d91b 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.module.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.module.spec.ts @@ -1,15 +1,16 @@ -import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { describe, expect, it, vi } from 'vitest'; import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; import { DockerEventService } from '@app/unraid-api/graph/resolvers/docker/docker-event.service.js'; -import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js'; +import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js'; import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.js'; import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js'; import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; +import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js'; +import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js'; describe('DockerModule', () => { it('should compile the module', async () => { @@ -18,6 +19,8 @@ describe('DockerModule', () => { }) .overrideProvider(DockerService) .useValue({ getDockerClient: vi.fn() }) + .overrideProvider(DockerOrganizerConfigService) + .useValue({ getConfig: vi.fn() }) .overrideProvider(DockerConfigService) .useValue({ getConfig: vi.fn() }) .compile(); @@ -61,6 +64,7 @@ describe('DockerModule', () => { DockerResolver, { provide: DockerService, useValue: {} }, { provide: DockerOrganizerService, useValue: {} }, + { provide: DockerPhpService, useValue: { getContainerUpdateStatuses: vi.fn() } }, ], }).compile(); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.module.ts b/api/src/unraid-api/graph/resolvers/docker/docker.module.ts index 824b84198e..22095f518d 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.module.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.module.ts @@ -1,22 +1,36 @@ import { Module } from '@nestjs/common'; +import { JobModule } from '@app/unraid-api/cron/job.module.js'; +import { ContainerStatusJob } from '@app/unraid-api/graph/resolvers/docker/container-status.job.js'; import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; -import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js'; +import { DockerContainerResolver } from '@app/unraid-api/graph/resolvers/docker/docker-container.resolver.js'; +import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js'; +import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js'; import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js'; import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; +import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js'; +import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js'; @Module({ + imports: [JobModule], providers: [ // Services DockerService, - DockerConfigService, + DockerOrganizerConfigService, DockerOrganizerService, + DockerManifestService, + DockerPhpService, + DockerConfigService, // DockerEventService, + // Jobs + ContainerStatusJob, + // Resolvers DockerResolver, DockerMutationsResolver, + DockerContainerResolver, ], exports: [DockerService], }) diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts index 01f2508b2d..a5cf4aeec8 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts @@ -3,10 +3,11 @@ import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js'; +import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js'; import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; +import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js'; describe('DockerResolver', () => { let resolver: DockerResolver; @@ -26,7 +27,13 @@ describe('DockerResolver', () => { { provide: DockerOrganizerService, useValue: { - getResolvedOrganizer: vi.fn(), + resolveOrganizer: vi.fn(), + }, + }, + { + provide: DockerPhpService, + useValue: { + getContainerUpdateStatuses: vi.fn(), }, }, ], diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts index 5948cc6e75..65a7276d47 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts @@ -3,21 +3,25 @@ import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js'; +import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js'; +import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js'; +import { ExplicitStatusItem } from '@app/unraid-api/graph/resolvers/docker/docker-update-status.model.js'; import { Docker, DockerContainer, DockerNetwork, } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; +import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js'; import { DEFAULT_ORGANIZER_ROOT_ID } from '@app/unraid-api/organizer/organizer.js'; -import { OrganizerV1, ResolvedOrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js'; +import { ResolvedOrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js'; @Resolver(() => Docker) export class DockerResolver { constructor( private readonly dockerService: DockerService, - private readonly dockerOrganizerService: DockerOrganizerService + private readonly dockerOrganizerService: DockerOrganizerService, + private readonly dockerPhpService: DockerPhpService ) {} @UsePermissions({ @@ -53,6 +57,7 @@ export class DockerResolver { return this.dockerService.getNetworks({ skipCache }); } + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') @UsePermissions({ action: AuthAction.READ_ANY, resource: Resource.DOCKER, @@ -62,6 +67,7 @@ export class DockerResolver { return this.dockerOrganizerService.resolveOrganizer(); } + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') @UsePermissions({ action: AuthAction.UPDATE_ANY, resource: Resource.DOCKER, @@ -80,6 +86,7 @@ export class DockerResolver { return this.dockerOrganizerService.resolveOrganizer(organizer); } + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') @UsePermissions({ action: AuthAction.UPDATE_ANY, resource: Resource.DOCKER, @@ -96,6 +103,7 @@ export class DockerResolver { return this.dockerOrganizerService.resolveOrganizer(organizer); } + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') @UsePermissions({ action: AuthAction.UPDATE_ANY, resource: Resource.DOCKER, @@ -108,6 +116,7 @@ export class DockerResolver { return this.dockerOrganizerService.resolveOrganizer(organizer); } + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') @UsePermissions({ action: AuthAction.UPDATE_ANY, resource: Resource.DOCKER, @@ -123,4 +132,14 @@ export class DockerResolver { }); return this.dockerOrganizerService.resolveOrganizer(organizer); } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DOCKER, + }) + @ResolveField(() => [ExplicitStatusItem]) + public async containerUpdateStatuses() { + return this.dockerPhpService.getContainerUpdateStatuses(); + } } diff --git a/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.ts b/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.ts new file mode 100644 index 0000000000..f627ee88d7 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { ConfigFilePersister } from '@unraid/shared/services/config-file.js'; + +import { FeatureFlags } from '@app/consts.js'; +import { AppError } from '@app/core/errors/app-error.js'; +import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js'; +import { + DEFAULT_ORGANIZER_ROOT_ID, + DEFAULT_ORGANIZER_VIEW_ID, +} from '@app/unraid-api/organizer/organizer.js'; +import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js'; +import { validateOrganizerIntegrity } from '@app/unraid-api/organizer/organizer.validation.js'; + +@Injectable() +export class DockerOrganizerConfigService extends ConfigFilePersister { + constructor(configService: ConfigService) { + super(configService); + } + + enabled(): boolean { + return FeatureFlags.ENABLE_NEXT_DOCKER_RELEASE; + } + + configKey(): string { + return 'dockerOrganizer'; + } + + fileName(): string { + return 'docker.organizer.json'; + } + + defaultConfig(): OrganizerV1 { + return { + version: 1, + resources: {}, + views: { + default: { + id: DEFAULT_ORGANIZER_VIEW_ID, + name: 'Default', + root: DEFAULT_ORGANIZER_ROOT_ID, + entries: { + root: { + type: 'folder', + id: DEFAULT_ORGANIZER_ROOT_ID, + name: 'Root', + children: [], + }, + }, + }, + }, + }; + } + + async validate(config: object): Promise { + const organizer = await validateObject(OrganizerV1, config); + const { isValid, errors } = await validateOrganizerIntegrity(organizer); + if (!isValid) { + throw new AppError(`Docker organizer validation failed: ${JSON.stringify(errors, null, 2)}`); + } + return organizer; + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.spec.ts similarity index 98% rename from api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.spec.ts rename to api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.spec.ts index b7159438c0..ecb0bb1a71 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.spec.ts @@ -2,17 +2,17 @@ import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; -import { - containerToResource, - DockerOrganizerService, -} from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js'; import { ContainerPortType, ContainerState, DockerContainer, } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; +import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js'; +import { + containerToResource, + DockerOrganizerService, +} from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js'; import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js'; describe('containerToResource', () => { @@ -138,7 +138,7 @@ describe('containerToResource', () => { describe('DockerOrganizerService', () => { let service: DockerOrganizerService; - let configService: DockerConfigService; + let configService: DockerOrganizerConfigService; let dockerService: DockerService; const mockOrganizer: OrganizerV1 = { @@ -178,7 +178,7 @@ describe('DockerOrganizerService', () => { providers: [ DockerOrganizerService, { - provide: DockerConfigService, + provide: DockerOrganizerConfigService, useValue: { getConfig: vi.fn().mockImplementation(() => structuredClone(mockOrganizer)), validate: vi.fn().mockImplementation((config) => Promise.resolve(config)), @@ -220,7 +220,7 @@ describe('DockerOrganizerService', () => { }).compile(); service = moduleRef.get(DockerOrganizerService); - configService = moduleRef.get(DockerConfigService); + configService = moduleRef.get(DockerOrganizerConfigService); dockerService = moduleRef.get(DockerService); }); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.ts b/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.ts similarity index 97% rename from api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.ts rename to api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.ts index 8f2f7ec2bf..41dff8257d 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-organizer.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.ts @@ -3,9 +3,9 @@ import { Injectable, Logger } from '@nestjs/common'; import type { ContainerListOptions } from 'dockerode'; import { AppError } from '@app/core/errors/app-error.js'; -import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; +import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js'; import { addMissingResourcesToView, createFolderInView, @@ -47,7 +47,7 @@ export function containerListToResourcesObject(containers: DockerContainer[]): O export class DockerOrganizerService { private readonly logger = new Logger(DockerOrganizerService.name); constructor( - private readonly dockerConfigService: DockerConfigService, + private readonly dockerConfigService: DockerOrganizerConfigService, private readonly dockerService: DockerService ) {} diff --git a/api/src/unraid-api/graph/resolvers/docker/utils/docker-push-parser.test.ts b/api/src/unraid-api/graph/resolvers/docker/utils/docker-push-parser.test.ts new file mode 100644 index 0000000000..f878396915 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/utils/docker-push-parser.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; + +import type { DockerPushMatch } from '@app/unraid-api/graph/resolvers/docker/utils/docker-push-parser.js'; +import { parseDockerPushCalls } from '@app/unraid-api/graph/resolvers/docker/utils/docker-push-parser.js'; + +describe('parseDockerPushCalls', () => { + it('should extract name and update status from valid docker.push call', () => { + const jsCode = "docker.push({name:'nginx',update:1});"; + const result = parseDockerPushCalls(jsCode); + + expect(result).toEqual([{ name: 'nginx', updateStatus: 1 }]); + }); + + it('should handle multiple docker.push calls in same string', () => { + const jsCode = ` + docker.push({name:'nginx',update:1}); + docker.push({name:'mysql',update:0}); + docker.push({name:'redis',update:2}); + `; + const result = parseDockerPushCalls(jsCode); + + expect(result).toEqual([ + { name: 'nginx', updateStatus: 1 }, + { name: 'mysql', updateStatus: 0 }, + { name: 'redis', updateStatus: 2 }, + ]); + }); + + it('should handle docker.push calls with additional properties', () => { + const jsCode = + "docker.push({id:'123',name:'nginx',version:'latest',update:3,status:'running'});"; + const result = parseDockerPushCalls(jsCode); + + expect(result).toEqual([{ name: 'nginx', updateStatus: 3 }]); + }); + + it('should handle different property order', () => { + const jsCode = "docker.push({update:2,name:'postgres',id:'456'});"; + const result = parseDockerPushCalls(jsCode); + + expect(result).toEqual([{ name: 'postgres', updateStatus: 2 }]); + }); + + it('should handle container names with special characters', () => { + const jsCode = "docker.push({name:'my-app_v2.0',update:1});"; + const result = parseDockerPushCalls(jsCode); + + expect(result).toEqual([{ name: 'my-app_v2.0', updateStatus: 1 }]); + }); + + it('should handle whitespace variations', () => { + const jsCode = "docker.push({ name: 'nginx' , update: 1 });"; + const result = parseDockerPushCalls(jsCode); + + expect(result).toEqual([{ name: 'nginx', updateStatus: 1 }]); + }); + + it('should return empty array for empty string', () => { + const result = parseDockerPushCalls(''); + expect(result).toEqual([]); + }); + + it('should return empty array when no docker.push calls found', () => { + const jsCode = "console.log('no docker calls here');"; + const result = parseDockerPushCalls(jsCode); + expect(result).toEqual([]); + }); + + it('should ignore malformed docker.push calls', () => { + const jsCode = ` + docker.push({name:'valid',update:1}); + docker.push({name:'missing-update'}); + docker.push({update:2}); + docker.push({name:'another-valid',update:0}); + `; + const result = parseDockerPushCalls(jsCode); + + expect(result).toEqual([ + { name: 'valid', updateStatus: 1 }, + { name: 'another-valid', updateStatus: 0 }, + ]); + }); + + it('should handle all valid update status values', () => { + const jsCode = ` + docker.push({name:'container0',update:0}); + docker.push({name:'container1',update:1}); + docker.push({name:'container2',update:2}); + docker.push({name:'container3',update:3}); + `; + const result = parseDockerPushCalls(jsCode); + + expect(result).toEqual([ + { name: 'container0', updateStatus: 0 }, + { name: 'container1', updateStatus: 1 }, + { name: 'container2', updateStatus: 2 }, + { name: 'container3', updateStatus: 3 }, + ]); + }); + + it('should handle real-world example with HTML and multiple containers', () => { + const jsCode = ` +
some html
+ docker.push({id:'abc123',name:'plex',version:'1.32',update:1,autostart:true}); + docker.push({id:'def456',name:'nextcloud',version:'latest',update:0,ports:'80:8080'}); + + docker.push({id:'ghi789',name:'homeassistant',update:2}); + `; + const result = parseDockerPushCalls(jsCode); + + expect(result).toEqual([ + { name: 'plex', updateStatus: 1 }, + { name: 'nextcloud', updateStatus: 0 }, + { name: 'homeassistant', updateStatus: 2 }, + ]); + }); + + it('should handle nested braces in other properties', () => { + const jsCode = 'docker.push({config:\'{"nested":"value"}\',name:\'test\',update:1});'; + const result = parseDockerPushCalls(jsCode); + + expect(result).toEqual([{ name: 'test', updateStatus: 1 }]); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/docker/utils/docker-push-parser.ts b/api/src/unraid-api/graph/resolvers/docker/utils/docker-push-parser.ts new file mode 100644 index 0000000000..bc96b1b0bd --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/utils/docker-push-parser.ts @@ -0,0 +1,24 @@ +export interface DockerPushMatch { + name: string; + updateStatus: number; +} + +export function parseDockerPushCalls(jsCode: string): DockerPushMatch[] { + const dockerPushRegex = /docker\.push\(\{[^}]*(?:(?:[^{}]|{[^}]*})*)\}\);/g; + const matches: DockerPushMatch[] = []; + + for (const match of jsCode.matchAll(dockerPushRegex)) { + const objectContent = match[0]; + + const nameMatch = objectContent.match(/name\s*:\s*'([^']+)'/); + const updateMatch = objectContent.match(/update\s*:\s*(\d)/); + + if (nameMatch && updateMatch) { + const name = nameMatch[1]; + const updateStatus = Number(updateMatch[1]); + matches.push({ name, updateStatus }); + } + } + + return matches; +} diff --git a/api/src/unraid-api/utils/feature-flag.helper.ts b/api/src/unraid-api/utils/feature-flag.helper.ts new file mode 100644 index 0000000000..24c8e868a7 --- /dev/null +++ b/api/src/unraid-api/utils/feature-flag.helper.ts @@ -0,0 +1,28 @@ +import { ForbiddenException } from '@nestjs/common'; + +/** + * Checks if a feature flag is enabled and throws an exception if disabled. + * Use this at the beginning of resolver methods for immediate feature flag checks. + * + * @example + * ```typescript + * @ResolveField(() => String) + * async organizer() { + * checkFeatureFlag(FeatureFlags, 'ENABLE_NEXT_DOCKER_RELEASE'); + * return this.dockerOrganizerService.resolveOrganizer(); + * } + * ``` + * + * @param flags - The feature flag object containing boolean/truthy values + * @param key - The key within the feature flag object to check + * @throws ForbiddenException if the feature flag is disabled + */ +export function checkFeatureFlag>(flags: T, key: keyof T): void { + const isEnabled = Boolean(flags[key]); + + if (!isEnabled) { + throw new ForbiddenException( + `Feature "${String(key)}" is currently disabled. This functionality is not available at this time.` + ); + } +} diff --git a/api/vite.config.ts b/api/vite.config.ts index 02706e4ca3..bddf826b48 100644 --- a/api/vite.config.ts +++ b/api/vite.config.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; + import type { ViteUserConfig } from 'vitest/config'; import { viteCommonjs } from '@originjs/vite-plugin-commonjs'; import nodeResolve from '@rollup/plugin-node-resolve'; @@ -70,6 +73,29 @@ export default defineConfig(({ mode }): ViteUserConfig => { }, }, }), + // Copy PHP files to assets directory + { + name: 'copy-php-files', + buildStart() { + const phpFiles = ['src/core/utils/plugins/wrapper.php']; + phpFiles.forEach((file) => this.addWatchFile(file)); + }, + async generateBundle() { + const phpFiles = ['src/core/utils/plugins/wrapper.php']; + phpFiles.forEach((file) => { + if (!existsSync(file)) { + this.warn(`[copy-php-files] PHP file ${file} does not exist`); + return; + } + const content = readFileSync(file); + this.emitFile({ + type: 'asset', + fileName: join('assets', basename(file)), + source: content, + }); + }); + }, + }, ], define: { // Allows vite to preserve process.env variables and not hardcode them diff --git a/packages/unraid-shared/src/util/__tests__/processing.test.ts b/packages/unraid-shared/src/util/__tests__/processing.test.ts new file mode 100644 index 0000000000..9f7d7a0b4e --- /dev/null +++ b/packages/unraid-shared/src/util/__tests__/processing.test.ts @@ -0,0 +1,295 @@ +import { describe, it, expect, vi } from 'vitest'; +import { AsyncMutex } from '../processing.js'; + +describe('AsyncMutex', () => { + + describe('constructor-based operation', () => { + it('should execute the default operation when do() is called without parameters', async () => { + const mockOperation = vi.fn().mockResolvedValue('result'); + const mutex = new AsyncMutex(mockOperation); + + const result = await mutex.do(); + + expect(result).toBe('result'); + expect(mockOperation).toHaveBeenCalledTimes(1); + }); + + it('should return the same promise when multiple calls are made concurrently', async () => { + let resolveOperation: (value: string) => void; + const operationPromise = new Promise((resolve) => { + resolveOperation = resolve; + }); + const mockOperation = vi.fn().mockReturnValue(operationPromise); + const mutex = new AsyncMutex(mockOperation); + + const promise1 = mutex.do(); + const promise2 = mutex.do(); + const promise3 = mutex.do(); + + expect(mockOperation).toHaveBeenCalledTimes(1); + expect(promise1).toBe(promise2); + expect(promise2).toBe(promise3); + + resolveOperation!('result'); + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); + + expect(result1).toBe('result'); + expect(result2).toBe('result'); + expect(result3).toBe('result'); + }); + + it('should allow new operations after the first completes', async () => { + const mockOperation = vi.fn() + .mockResolvedValueOnce('first') + .mockResolvedValueOnce('second'); + const mutex = new AsyncMutex(mockOperation); + + const result1 = await mutex.do(); + expect(result1).toBe('first'); + expect(mockOperation).toHaveBeenCalledTimes(1); + + const result2 = await mutex.do(); + expect(result2).toBe('second'); + expect(mockOperation).toHaveBeenCalledTimes(2); + }); + + it('should handle errors in the default operation', async () => { + const error = new Error('Operation failed'); + const mockOperation = vi.fn().mockRejectedValue(error); + const mutex = new AsyncMutex(mockOperation); + + await expect(mutex.do()).rejects.toThrow(error); + expect(mockOperation).toHaveBeenCalledTimes(1); + + const secondOperation = vi.fn().mockResolvedValue('success'); + const mutex2 = new AsyncMutex(secondOperation); + const result = await mutex2.do(); + expect(result).toBe('success'); + }); + }); + + describe('per-call operation', () => { + it('should execute the provided operation', async () => { + const mutex = new AsyncMutex(); + const mockOperation = vi.fn().mockResolvedValue(42); + + const result = await mutex.do(mockOperation); + + expect(result).toBe(42); + expect(mockOperation).toHaveBeenCalledTimes(1); + }); + + it('should return the same promise for concurrent calls with same operation type', async () => { + const mutex = new AsyncMutex(); + let resolveOperation: (value: string) => void; + const operationPromise = new Promise((resolve) => { + resolveOperation = resolve; + }); + const mockOperation = vi.fn().mockReturnValue(operationPromise); + + const promise1 = mutex.do(mockOperation); + const promise2 = mutex.do(mockOperation); + const promise3 = mutex.do(mockOperation); + + expect(mockOperation).toHaveBeenCalledTimes(1); + expect(promise1).toBe(promise2); + expect(promise2).toBe(promise3); + + resolveOperation!('shared-result'); + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); + + expect(result1).toBe('shared-result'); + expect(result2).toBe('shared-result'); + expect(result3).toBe('shared-result'); + }); + + it('should allow different operations with different types', async () => { + const mutex = new AsyncMutex(); + + const stringOp = vi.fn().mockResolvedValue('string-result'); + const numberOp = vi.fn().mockResolvedValue(123); + + const stringResult = await mutex.do(stringOp); + const numberResult = await mutex.do(numberOp); + + expect(stringResult).toBe('string-result'); + expect(numberResult).toBe(123); + expect(stringOp).toHaveBeenCalledTimes(1); + expect(numberOp).toHaveBeenCalledTimes(1); + }); + + it('should handle errors in per-call operations', async () => { + const mutex = new AsyncMutex(); + const error = new Error('Operation failed'); + const failingOp = vi.fn().mockRejectedValue(error); + + await expect(mutex.do(failingOp)).rejects.toThrow(error); + expect(failingOp).toHaveBeenCalledTimes(1); + + const successOp = vi.fn().mockResolvedValue('success'); + const result = await mutex.do(successOp); + expect(result).toBe('success'); + expect(successOp).toHaveBeenCalledTimes(1); + }); + + it('should throw an error when no operation is provided and no default is set', async () => { + const mutex = new AsyncMutex(); + + await expect(mutex.do()).rejects.toThrow('No operation provided and no default operation set'); + }); + }); + + describe('mixed usage', () => { + it('should allow overriding default operation with per-call operation', async () => { + const defaultOp = vi.fn().mockResolvedValue('default'); + const mutex = new AsyncMutex(defaultOp); + + const customOp = vi.fn().mockResolvedValue('custom'); + + const customResult = await mutex.do(customOp); + expect(customResult).toBe('custom'); + expect(customOp).toHaveBeenCalledTimes(1); + expect(defaultOp).not.toHaveBeenCalled(); + + const defaultResult = await mutex.do(); + expect(defaultResult).toBe('default'); + expect(defaultOp).toHaveBeenCalledTimes(1); + }); + + it('should share lock between default and custom operations', async () => { + let resolveDefault: (value: string) => void; + const defaultPromise = new Promise((resolve) => { + resolveDefault = resolve; + }); + const defaultOp = vi.fn().mockReturnValue(defaultPromise); + const mutex = new AsyncMutex(defaultOp); + + const customOp = vi.fn().mockResolvedValue('custom'); + + const defaultCall = mutex.do(); + const customCall = mutex.do(customOp); + + expect(defaultOp).toHaveBeenCalledTimes(1); + expect(customOp).not.toHaveBeenCalled(); + expect(customCall).toBe(defaultCall); + + resolveDefault!('default'); + const [defaultResult, customResult] = await Promise.all([defaultCall, customCall]); + + expect(defaultResult).toBe('default'); + expect(customResult).toBe('default'); + }); + }); + + describe('timing and concurrency', () => { + it('should handle sequential slow operations', async () => { + const mutex = new AsyncMutex(); + let callCount = 0; + + const slowOp = vi.fn().mockImplementation(() => { + return new Promise((resolve) => { + const currentCall = ++callCount; + setTimeout(() => resolve(`result-${currentCall}`), 100); + }); + }); + + const result1 = await mutex.do(slowOp); + expect(result1).toBe('result-1'); + + const result2 = await mutex.do(slowOp); + expect(result2).toBe('result-2'); + + expect(slowOp).toHaveBeenCalledTimes(2); + }); + + it('should deduplicate concurrent slow operations', async () => { + const mutex = new AsyncMutex(); + let resolveOperation: (value: string) => void; + + const slowOp = vi.fn().mockImplementation(() => { + return new Promise((resolve) => { + resolveOperation = resolve; + }); + }); + + const promises = [ + mutex.do(slowOp), + mutex.do(slowOp), + mutex.do(slowOp), + mutex.do(slowOp), + mutex.do(slowOp) + ]; + + expect(slowOp).toHaveBeenCalledTimes(1); + + resolveOperation!('shared-slow-result'); + const results = await Promise.all(promises); + + expect(results).toEqual([ + 'shared-slow-result', + 'shared-slow-result', + 'shared-slow-result', + 'shared-slow-result', + 'shared-slow-result' + ]); + }); + + it('should properly clean up after operation completes', async () => { + const mutex = new AsyncMutex(); + const op1 = vi.fn().mockResolvedValue('first'); + const op2 = vi.fn().mockResolvedValue('second'); + + await mutex.do(op1); + expect(op1).toHaveBeenCalledTimes(1); + + await mutex.do(op2); + expect(op2).toHaveBeenCalledTimes(1); + }); + + it('should handle multiple rapid sequences of operations', async () => { + const mutex = new AsyncMutex(); + const results: string[] = []; + + for (let i = 0; i < 5; i++) { + const op = vi.fn().mockResolvedValue(`result-${i}`); + const result = await mutex.do(op); + results.push(result as string); + } + + expect(results).toEqual(['result-0', 'result-1', 'result-2', 'result-3', 'result-4']); + }); + }); + + describe('edge cases', () => { + it('should handle operations that return undefined', async () => { + const mutex = new AsyncMutex(); + const op = vi.fn().mockResolvedValue(undefined); + + const result = await mutex.do(op); + expect(result).toBeUndefined(); + expect(op).toHaveBeenCalledTimes(1); + }); + + it('should handle operations that return null', async () => { + const mutex = new AsyncMutex(); + const op = vi.fn().mockResolvedValue(null); + + const result = await mutex.do(op); + expect(result).toBeNull(); + expect(op).toHaveBeenCalledTimes(1); + }); + + it('should handle nested operations correctly', async () => { + const mutex = new AsyncMutex(); + + const innerOp = vi.fn().mockResolvedValue('inner'); + const outerOp = vi.fn().mockImplementation(async () => { + return 'outer'; + }); + + const result = await mutex.do(outerOp); + expect(result).toBe('outer'); + expect(outerOp).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/unraid-shared/src/util/processing.ts b/packages/unraid-shared/src/util/processing.ts index 37b6e6991e..f884f04b7a 100644 --- a/packages/unraid-shared/src/util/processing.ts +++ b/packages/unraid-shared/src/util/processing.ts @@ -31,3 +31,119 @@ export function makeSafeRunner(onError: (error: unknown) => void) { } }; } + +type AsyncOperation = () => Promise; + +/** + * A mutex for asynchronous operations that ensures only one operation runs at a time. + * + * When multiple callers attempt to execute operations simultaneously, they will all + * receive the same promise from the currently running operation, effectively deduplicating + * concurrent calls. This is useful for expensive operations like API calls, file operations, + * or database queries that should not be executed multiple times concurrently. + * + * @template T - The default return type for operations when using a default operation + * + * @example + * // Basic usage with explicit operations + * const mutex = new AsyncMutex(); + * + * // Multiple concurrent calls will deduplicate + * const [result1, result2, result3] = await Promise.all([ + * mutex.do(() => fetch('/api/data')), + * mutex.do(() => fetch('/api/data')), // Same request, will get same promise + * mutex.do(() => fetch('/api/data')) // Same request, will get same promise + * ]); + * // Only one fetch actually happens + * + * @example + * // Usage with a default operation + * const dataLoader = new AsyncMutex(() => + * fetch('/api/expensive-data').then(res => res.json()) + * ); + * + * const data1 = await dataLoader.do(); // Executes the fetch + * const data2 = await dataLoader.do(); // If first promise is finished, a new fetch is executed + */ +export class AsyncMutex { + private currentOperation: Promise | null = null; + private defaultOperation?: AsyncOperation; + + /** + * Creates a new AsyncMutex instance. + * + * @param operation - Optional default operation to execute when calling `do()` without arguments. + * This is useful when you have a specific operation that should be deduplicated. + * + * @example + * // Without default operation (shared mutex) + * const mutex = new AsyncMutex(); + * const promise1 = mutex.do(() => someAsyncWork()); + * const promise2 = mutex.do(() => someOtherAsyncWork()); + * + * // Both promises will be the same + * expect(await promise1).toBe(await promise2); + * + * // After the first operation completes, new operations can run + * await promise1; + * const newPromise = mutex.do(() => someOtherAsyncWork()); // This will execute + * + * @example + * // With default operation (deduplicating a specific operation) + * const dataMutex = new AsyncMutex(() => loadExpensiveData()); + * await dataMutex.do(); // Executes loadExpensiveData() + */ + constructor(operation?: AsyncOperation) { + this.defaultOperation = operation; + } + + /** + * Executes the provided operation, ensuring only one runs at a time. + * + * If an operation is already running, all subsequent calls will receive + * the same promise from the currently running operation. This effectively + * deduplicates concurrent calls to the same expensive operation. + * + * @param operation - Optional operation to execute. If not provided, uses the default operation. + * @returns Promise that resolves with the result of the operation + * @throws Error if no operation is provided and no default operation was set + * + * @example + * const mutex = new AsyncMutex(); + * + * // These will all return the same promise + * const promise1 = mutex.do(() => fetch('/api/data')); + * const promise2 = mutex.do(() => fetch('/api/other')); // Still gets first promise! + * const promise3 = mutex.do(() => fetch('/api/another')); // Still gets first promise! + * + * // After the first operation completes, new operations can run + * await promise1; + * const newPromise = mutex.do(() => fetch('/api/new')); // This will execute + */ + do(operation?: AsyncOperation): Promise { + if (this.currentOperation) { + return this.currentOperation; + } + const op = operation ?? this.defaultOperation; + if (!op) { + return Promise.reject( + new Error("No operation provided and no default operation set") + ); + } + const safeOp = () => { + try { + return op(); + } catch (error) { + return Promise.reject(error); + } + }; + + const promise = safeOp().finally(() => { + if (this.currentOperation === promise) { + this.currentOperation = null; + } + }); + this.currentOperation = promise; + return promise; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18069905dd..4c2726729e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,8 +164,8 @@ importers: specifier: 1.0.2 version: 1.0.2 cron: - specifier: 4.3.3 - version: 4.3.3 + specifier: 4.3.0 + version: 4.3.0 cross-fetch: specifier: 4.1.0 version: 4.1.0 @@ -4201,9 +4201,6 @@ packages: '@types/luxon@3.6.2': resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==} - '@types/luxon@3.7.1': - resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} - '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} @@ -5868,10 +5865,6 @@ packages: resolution: {integrity: sha512-ciiYNLfSlF9MrDqnbMdRWFiA6oizSF7kA1osPP9lRzNu0Uu+AWog1UKy7SkckiDY2irrNjeO6qLyKnXC8oxmrw==} engines: {node: '>=18.x'} - cron@4.3.3: - resolution: {integrity: sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==} - engines: {node: '>=18.x'} - croner@4.1.97: resolution: {integrity: sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ==} @@ -14806,8 +14799,6 @@ snapshots: '@types/luxon@3.6.2': {} - '@types/luxon@3.7.1': {} - '@types/mdx@2.0.13': {} '@types/methods@1.1.4': {} @@ -16673,11 +16664,6 @@ snapshots: '@types/luxon': 3.6.2 luxon: 3.6.1 - cron@4.3.3: - dependencies: - '@types/luxon': 3.7.1 - luxon: 3.7.1 - croner@4.1.97: {} cross-fetch@3.2.0: