Skip to content

Commit e57d81e

Browse files
authored
feat(api): determine if docker container has update (#1582)
- Add a new utility class, `AsyncMutex` in `unraid-shared -> processing.ts`, for ergonomically de-duplicating async operations. - Add an `@OmitIf` decorator for omitting graphql queries, mutations, or field resolvers from the runtime graphql schema. - Add feature-flagging system - `FeatureFlags` export from `consts.ts` - `@UseFeatureFlag` decorator built upon `OmitIf` - `checkFeatureFlag` for constructing & throwing a `ForbiddenError` if the given feature flag evaluates to `false`. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Expose disk spinning state, per-container "update available" and "rebuild ready" indicators, a structured per-container update-status list, and a mutation to refresh Docker digests. Periodic and post-startup digest refreshes added (feature-flag gated). * **Chores** * Cron scheduling refactor and scheduler centralization. * Build now bundles a PHP wrapper asset. * Added feature-flag env var and .gitignore entry for local keys. * **Documentation** * Added developer guide for feature flags. * **Tests** * New concurrency, parser, decorator, config, and mutex test suites. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 88baddd commit e57d81e

37 files changed

+2183
-74
lines changed

api/.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@ BYPASS_CORS_CHECKS=true
3131
CHOKIDAR_USEPOLLING=true
3232
LOG_TRANSPORT=console
3333
LOG_LEVEL=trace
34+
ENABLE_NEXT_DOCKER_RELEASE=true

api/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,6 @@ dev/local-session
9393

9494
# local OIDC config for testing - contains secrets
9595
dev/configs/oidc.local.json
96+
97+
# local api keys
98+
dev/keys/*
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
# Feature Flags
2+
3+
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.
4+
5+
## Setting Up Feature Flags
6+
7+
### 1. Define the Feature Flag
8+
9+
Feature flags are defined as environment variables and collected in `src/consts.ts`:
10+
11+
```typescript
12+
// src/environment.ts
13+
export const ENABLE_MY_NEW_FEATURE = process.env.ENABLE_MY_NEW_FEATURE === 'true';
14+
15+
// src/consts.ts
16+
export const FeatureFlags = Object.freeze({
17+
ENABLE_NEXT_DOCKER_RELEASE,
18+
ENABLE_MY_NEW_FEATURE, // Add your new flag here
19+
});
20+
```
21+
22+
### 2. Set the Environment Variable
23+
24+
Set the environment variable when running the API:
25+
26+
```bash
27+
ENABLE_MY_NEW_FEATURE=true unraid-api start
28+
```
29+
30+
Or add it to your `.env` file:
31+
32+
```env
33+
ENABLE_MY_NEW_FEATURE=true
34+
```
35+
36+
## Using Feature Flags in GraphQL
37+
38+
### Method 1: @UseFeatureFlag Decorator (Schema-Level)
39+
40+
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.
41+
42+
```typescript
43+
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
44+
import { Query, Mutation, ResolveField } from '@nestjs/graphql';
45+
46+
@Resolver()
47+
export class MyResolver {
48+
49+
// Conditionally include a query
50+
@UseFeatureFlag('ENABLE_MY_NEW_FEATURE')
51+
@Query(() => String)
52+
async experimentalQuery() {
53+
return 'This query only exists when ENABLE_MY_NEW_FEATURE is true';
54+
}
55+
56+
// Conditionally include a mutation
57+
@UseFeatureFlag('ENABLE_MY_NEW_FEATURE')
58+
@Mutation(() => Boolean)
59+
async experimentalMutation() {
60+
return true;
61+
}
62+
63+
// Conditionally include a field resolver
64+
@UseFeatureFlag('ENABLE_MY_NEW_FEATURE')
65+
@ResolveField(() => String)
66+
async experimentalField() {
67+
return 'This field only exists when the flag is enabled';
68+
}
69+
}
70+
```
71+
72+
**Benefits:**
73+
- Clean schema - disabled features don't appear in GraphQL introspection
74+
- No runtime overhead for disabled features
75+
- Clear feature boundaries
76+
77+
**Use when:**
78+
- You want to completely hide features from the GraphQL schema
79+
- The feature is experimental or in beta
80+
- You're doing a gradual rollout
81+
82+
### Method 2: checkFeatureFlag Function (Runtime)
83+
84+
The `checkFeatureFlag` function provides runtime feature flag checking within resolver methods. It throws a `ForbiddenException` if the feature is disabled.
85+
86+
```typescript
87+
import { checkFeatureFlag } from '@app/unraid-api/utils/feature-flag.helper.js';
88+
import { FeatureFlags } from '@app/consts.js';
89+
import { Query, ResolveField } from '@nestjs/graphql';
90+
91+
@Resolver()
92+
export class MyResolver {
93+
94+
@Query(() => String)
95+
async myQuery(
96+
@Args('useNewAlgorithm', { nullable: true }) useNewAlgorithm?: boolean
97+
) {
98+
// Conditionally use new logic based on feature flag
99+
if (useNewAlgorithm) {
100+
checkFeatureFlag(FeatureFlags, 'ENABLE_MY_NEW_FEATURE');
101+
return this.newAlgorithm();
102+
}
103+
104+
return this.oldAlgorithm();
105+
}
106+
107+
@ResolveField(() => String)
108+
async dataField() {
109+
// Check flag at the start of the method
110+
checkFeatureFlag(FeatureFlags, 'ENABLE_MY_NEW_FEATURE');
111+
112+
// Feature-specific logic here
113+
return this.computeExperimentalData();
114+
}
115+
}
116+
```
117+
118+
**Benefits:**
119+
- More granular control within methods
120+
- Can conditionally execute parts of a method
121+
- Useful for A/B testing scenarios
122+
- Good for gradual migration strategies
123+
124+
**Use when:**
125+
- You need conditional logic within a method
126+
- The field should exist but behavior changes based on the flag
127+
- You're migrating from old to new implementation gradually
128+
129+
## Feature Flag Patterns
130+
131+
### Pattern 1: Complete Feature Toggle
132+
133+
Hide an entire feature behind a flag:
134+
135+
```typescript
136+
@UseFeatureFlag('ENABLE_DOCKER_TEMPLATES')
137+
@Resolver(() => DockerTemplate)
138+
export class DockerTemplateResolver {
139+
// All resolvers in this class are toggled by the flag
140+
}
141+
```
142+
143+
### Pattern 2: Gradual Migration
144+
145+
Migrate from old to new implementation:
146+
147+
```typescript
148+
@Query(() => [Container])
149+
async getContainers(@Args('version') version?: string) {
150+
if (version === 'v2') {
151+
checkFeatureFlag(FeatureFlags, 'ENABLE_CONTAINERS_V2');
152+
return this.getContainersV2();
153+
}
154+
155+
return this.getContainersV1();
156+
}
157+
```
158+
159+
### Pattern 3: Beta Features
160+
161+
Mark features as beta:
162+
163+
```typescript
164+
@UseFeatureFlag('ENABLE_BETA_FEATURES')
165+
@ResolveField(() => BetaMetrics, {
166+
description: 'BETA: Advanced metrics (requires ENABLE_BETA_FEATURES flag)'
167+
})
168+
async betaMetrics() {
169+
return this.computeBetaMetrics();
170+
}
171+
```
172+
173+
### Pattern 4: Performance Optimizations
174+
175+
Toggle expensive operations:
176+
177+
```typescript
178+
@ResolveField(() => Statistics)
179+
async statistics() {
180+
const basicStats = await this.getBasicStats();
181+
182+
try {
183+
checkFeatureFlag(FeatureFlags, 'ENABLE_ADVANCED_ANALYTICS');
184+
const advancedStats = await this.getAdvancedStats();
185+
return { ...basicStats, ...advancedStats };
186+
} catch {
187+
// Feature disabled, return only basic stats
188+
return basicStats;
189+
}
190+
}
191+
```
192+
193+
## Testing with Feature Flags
194+
195+
When writing tests for feature-flagged code, create a mock to control feature flag values:
196+
197+
```typescript
198+
import { vi } from 'vitest';
199+
200+
// Mock the entire consts module
201+
vi.mock('@app/consts.js', async () => {
202+
const actual = await vi.importActual('@app/consts.js');
203+
return {
204+
...actual,
205+
FeatureFlags: {
206+
ENABLE_MY_NEW_FEATURE: true, // Set your test value
207+
ENABLE_NEXT_DOCKER_RELEASE: false,
208+
}
209+
};
210+
});
211+
212+
describe('MyResolver', () => {
213+
it('should execute new logic when feature is enabled', async () => {
214+
// Test new behavior with mocked flag
215+
});
216+
});
217+
```
218+
219+
## Best Practices
220+
221+
1. **Naming Convention**: Use `ENABLE_` prefix for boolean feature flags
222+
2. **Environment Variables**: Always use uppercase with underscores
223+
3. **Documentation**: Document what each feature flag controls
224+
4. **Cleanup**: Remove feature flags once features are stable and fully rolled out
225+
5. **Default State**: New features should default to `false` (disabled)
226+
6. **Granularity**: Keep feature flags focused on a single feature or capability
227+
7. **Testing**: Always test both enabled and disabled states
228+
229+
## Common Use Cases
230+
231+
- **Experimental Features**: Hide unstable features in production
232+
- **Gradual Rollouts**: Enable features for specific environments first
233+
- **A/B Testing**: Toggle between different implementations
234+
- **Performance**: Disable expensive operations when not needed
235+
- **Breaking Changes**: Provide migration path with both old and new behavior
236+
- **Debug Features**: Enable additional logging or debugging tools
237+
238+
## Checking Active Feature Flags
239+
240+
To see which feature flags are currently active:
241+
242+
```typescript
243+
// Log all feature flags on startup
244+
console.log('Active Feature Flags:', FeatureFlags);
245+
```
246+
247+
Or check via GraphQL introspection to see which fields are available based on current flags.

api/generated-schema.graphql

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ type ArrayDisk implements Node {
139139
"""ata | nvme | usb | (others)"""
140140
transport: String
141141
color: ArrayDiskFsColor
142+
143+
"""Whether the disk is currently spinning"""
144+
isSpinning: Boolean
142145
}
143146

144147
interface Node {
@@ -346,6 +349,9 @@ type Disk implements Node {
346349

347350
"""The partitions on the disk"""
348351
partitions: [DiskPartition!]!
352+
353+
"""Whether the disk is spinning or not"""
354+
isSpinning: Boolean!
349355
}
350356

351357
"""The type of interface the disk uses to connect to the system"""
@@ -1044,6 +1050,19 @@ enum ThemeName {
10441050
white
10451051
}
10461052

1053+
type ExplicitStatusItem {
1054+
name: String!
1055+
updateStatus: UpdateStatus!
1056+
}
1057+
1058+
"""Update status of a container."""
1059+
enum UpdateStatus {
1060+
UP_TO_DATE
1061+
UPDATE_AVAILABLE
1062+
REBUILD_READY
1063+
UNKNOWN
1064+
}
1065+
10471066
type ContainerPort {
10481067
ip: String
10491068
privatePort: Port
@@ -1083,6 +1102,8 @@ type DockerContainer implements Node {
10831102
networkSettings: JSON
10841103
mounts: [JSON!]
10851104
autoStart: Boolean!
1105+
isUpdateAvailable: Boolean
1106+
isRebuildReady: Boolean
10861107
}
10871108

10881109
enum ContainerState {
@@ -1113,6 +1134,7 @@ type Docker implements Node {
11131134
containers(skipCache: Boolean! = false): [DockerContainer!]!
11141135
networks(skipCache: Boolean! = false): [DockerNetwork!]!
11151136
organizer: ResolvedOrganizerV1!
1137+
containerUpdateStatuses: [ExplicitStatusItem!]!
11161138
}
11171139

11181140
type ResolvedOrganizerView {
@@ -2413,6 +2435,7 @@ type Mutation {
24132435
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!
24142436
deleteDockerEntries(entryIds: [String!]!): ResolvedOrganizerV1!
24152437
moveDockerEntriesToFolder(sourceEntryIds: [String!]!, destinationFolderId: String!): ResolvedOrganizerV1!
2438+
refreshDockerDigests: Boolean!
24162439

24172440
"""Initiates a flash drive backup using a configured remote."""
24182441
initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus!

api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@
9494
"command-exists": "1.2.9",
9595
"convert": "5.12.0",
9696
"cookie": "1.0.2",
97-
"cron": "4.3.3",
97+
"cron": "4.3.0",
9898
"cross-fetch": "4.1.0",
9999
"diff": "8.0.2",
100100
"dockerode": "4.0.7",

api/src/consts.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { join } from 'path';
22

33
import type { JSONWebKeySet } from 'jose';
44

5-
import { PORT } from '@app/environment.js';
5+
import { ENABLE_NEXT_DOCKER_RELEASE, PORT } from '@app/environment.js';
66

77
export const getInternalApiAddress = (isHttp = true, nginxPort = 80) => {
88
const envPort = PORT;
@@ -79,3 +79,14 @@ export const KEYSERVER_VALIDATION_ENDPOINT = 'https://keys.lime-technology.com/v
7979

8080
/** Set the max retries for the GraphQL Client */
8181
export const MAX_RETRIES_FOR_LINEAR_BACKOFF = 100;
82+
83+
/**
84+
* Feature flags are used to conditionally enable or disable functionality in the Unraid API.
85+
*
86+
* Keys are human readable feature flag names -- will be used to construct error messages.
87+
*
88+
* Values are boolean/truthy values.
89+
*/
90+
export const FeatureFlags = Object.freeze({
91+
ENABLE_NEXT_DOCKER_RELEASE,
92+
});

api/src/environment.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,6 @@ export const PATHS_CONFIG_MODULES =
110110

111111
export const PATHS_LOCAL_SESSION_FILE =
112112
process.env.PATHS_LOCAL_SESSION_FILE ?? '/var/run/unraid-api/local-session';
113+
114+
/** feature flag for the upcoming docker release */
115+
export const ENABLE_NEXT_DOCKER_RELEASE = process.env.ENABLE_NEXT_DOCKER_RELEASE === 'true';

api/src/unraid-api/app/app.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
1414
import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js';
1515
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
1616
import { CronModule } from '@app/unraid-api/cron/cron.module.js';
17+
import { JobModule } from '@app/unraid-api/cron/job.module.js';
1718
import { GraphModule } from '@app/unraid-api/graph/graph.module.js';
1819
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
1920
import { RestModule } from '@app/unraid-api/rest/rest.module.js';
@@ -24,7 +25,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
2425
GlobalDepsModule,
2526
LegacyConfigModule,
2627
PubSubModule,
27-
ScheduleModule.forRoot(),
28+
JobModule,
2829
LoggerModule.forRoot({
2930
pinoHttp: {
3031
logger: apiLogger,
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { Module } from '@nestjs/common';
2-
import { ScheduleModule } from '@nestjs/schedule';
32

3+
import { JobModule } from '@app/unraid-api/cron/job.module.js';
44
import { LogRotateService } from '@app/unraid-api/cron/log-rotate.service.js';
55
import { WriteFlashFileService } from '@app/unraid-api/cron/write-flash-file.service.js';
66

77
@Module({
8-
imports: [],
8+
imports: [JobModule],
99
providers: [WriteFlashFileService, LogRotateService],
1010
})
1111
export class CronModule {}

0 commit comments

Comments
 (0)