Skip to content

feat: Add Features Settings Page For Optional Features - #1406

Open
Vad1mo wants to merge 5 commits into
mainfrom
feat/optional-features
Open

Vad1mo wants to merge 5 commits into
mainfrom
feat/optional-features

Conversation

@Vad1mo

@Vad1mo Vad1mo commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Adds a generic on/off gate for optional features and a Configuration > Features page that lists them. No feature is registered yet, so the tab stays hidden on a plain build. #1398 (Hugging Face model import) is the first planned user.

What it contains

  • src/pkg/feature: one feature.Register(Definition{...}) call per feature carries config key, env key (plus optional legacy name), section, name, description, beta and default. It creates the boolean config item itself; metadata.Register makes that possible after the config managers froze the item list.
    • an explicitly set environment variable wins over the stored value, which wins over the default; an unparsable value disables the feature and still locks it
    • disable guards (all must pass) can refuse switching a feature off while it is in use; a registry type can be gated by one feature
    • RegisterSection adds page sections; a section link is resolved per request, so it can carry the current state
  • API: GET /api/v2.0/features (sections and features with enabled, editable, locked_by) and PUT /api/v2.0/features/{key}, which goes through the config controller (env lock, guards, CONFIG_OVERWRITE_JSON). GET /systeminfo adds a features map for UI gating.
  • Portal: Configuration > Features renders sections and toggles from the API; the tab shows once any feature is registered.
  • OpenFeature-ready without the SDK: the flag key is the config key, Enabled(ctx, ...) keeps the context, and Resolve / OnChange are exported so a provider can be added in its own package later.

Adding a feature (as done in #1398)

func init() {
	feature.Register(feature.Definition{
		Feature: "huggingface_model_import", ConfigKey: "enable_huggingface_model_import",
		EnvKey: "HARBOR_ENABLE_HUGGINGFACE_MODEL_IMPORT", Name: "...", Description: "...", Beta: true,
	})
}

That is the whole toggle: no swagger field, no metadata file, no portal entry. Gate code paths with feature.Enabled(ctx, f), a registry type with feature.RegisterRegistryTypes, UI with AppConfigService.featureEnabled(key).

Verified

  • Go: pkg/feature, lib/config/..., pkg/config/..., controller/config, server/v2.0/handler (incl. API tests for 401/403/404/422/400) with -race; golangci-lint v2.15.0: 0 issues
  • karma left-side-nav/config/**: 45/45; tsc on the whole portal clean
  • Dev stack with throwaway registrations (not in this PR): list, save, default-on feature switched off, values persist across a core restart, section link reflects the saved state, false is serialized, env lock and PUT errors as above

Screenshot

Example: #1398 (Hugging Face model import) registered as the first optional feature, on top of this PR (branch feat/hf-model-import-option-b @ 99f1cd6). Dev stack on slot 0, toggle switched on and saved.

Configuration > Features with Hugging Face Model Import (beta) as optional feature

Release Notes

Configuration has a new Features tab for switching optional features on and off, also available as GET/PUT /api/v2.0/features. An environment variable HARBOR_ENABLE_<FEATURE> takes precedence and locks the setting in the UI. The tab appears once a release ships an optional feature.

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

🔒 Vulnerability Check Results

⚠️ Fixable vulnerabilities detected: 4 of 7 found

Vulnerability ID Package Found In Fixed In Description Example Trace Details
GO-2026-4942 github.com/distribution/distribution v2.8.2+incompatible 3.1.0 Stale blob descriptor cache invalidation in github.com/distribution/distribution github.com/distribution/distribution View
GO-2026-5094 github.com/distribution/distribution v2.8.2+incompatible 3.1.0 Distribution affected by pull-through cache credential exfiltration via www-authenticate bearer realm in github.com/distribution/distribution github.com/distribution/distribution View
GO-2026-5185 github.com/distribution/distribution v2.8.2+incompatible 3.1.1 Distribution's tag deletion bypasses storage.delete.enabled configuration in github.com/distribution/distribution github.com/distribution/distribution View
GO-2026-5932 golang.org/x/crypto v0.58.0 0.58.1-0.20261009181718-2f1f834330b2 The golang.org/x/crypto/openpgp package is unmaintained, unsafe by design, and has known security issues golang.org/x/crypto View

Showing 4 of 7 total findings.

govulncheck exit code: 0

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 24 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/pkg/feature/feature.go Resolved
Comment thread src/pkg/feature/feature.go Resolved
Signed-off-by: Vad1mo <vadim@8gears.com>
@Vad1mo
Vad1mo force-pushed the feat/optional-features branch from 0c92975 to c7c9a5a Compare November 5, 2026 15:06
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Preview images for this PR are available in 8gears.container-registry.com/8gcr-pr with tag pr-1406, built from 470dc46:

  • 8gears.container-registry.com/8gcr-pr/harbor-core:pr-1406
  • 8gears.container-registry.com/8gcr-pr/harbor-jobservice:pr-1406
  • 8gears.container-registry.com/8gcr-pr/harbor-registryctl:pr-1406
  • 8gears.container-registry.com/8gcr-pr/harbor-exporter:pr-1406
  • 8gears.container-registry.com/8gcr-pr/harbor-portal:pr-1406
  • 8gears.container-registry.com/8gcr-pr/harbor-registry:pr-1406
  • 8gears.container-registry.com/8gcr-pr/trivy-adapter:pr-1406

Verify a preview image:

cosign verify \
  --certificate-identity-regexp="https://github.com/container-registry/harbor-next/.github/workflows/pr-ci.yml@.*" \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
  8gears.container-registry.com/8gcr-pr/harbor-core:pr-1406

Verify the SBOM attestation:

cosign verify-attestation \
  --certificate-identity-regexp="https://github.com/container-registry/harbor-next/.github/workflows/pr-ci.yml@.*" \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
  --type spdxjson \
  8gears.container-registry.com/8gcr-pr/harbor-core:pr-1406

- keep the beta badge and info icon from toggling the feature
- keep Save, Cancel and toggles disabled until the config reload finishes
- run every disable guard registered for a feature instead of the last one
- refuse a registry type claimed by two features

Signed-off-by: Vad1mo <vadim@8gears.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 issues found across 24 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/lib/config/metadata/metadata_test.go">

<violation number="1" location="src/lib/config/metadata/metadata_test.go:54">
P2: This test permanently mutates the package-global `ConfigList`, so repeated test runs in one process fail when the synthetic key is already registered. Snapshot `ConfigList` and restore it with `t.Cleanup`, then reinitialize the metadata singleton.</violation>
</file>

<file name="src/controller/systeminfo/controller.go">

<violation number="1" location="src/controller/systeminfo/controller.go:142">
P2: This breaks the existing `TestGetInfo` case: `enabledFeatures` always returns a non-nil empty map, but the test expects `Features` to be nil. Either set `Features: map[string]bool{}` in that expectation in `controller_test.go`, or return nil from `enabledFeatures` when there are no features.</violation>
</file>

<file name="src/controller/config/controller.go">

<violation number="1" location="src/controller/config/controller.go:98">
P2: `feature.NotifyChanged` runs only on the replica that handled the request. Other replicas pick up the new setting through the Postgres LISTEN/NOTIFY sync but never fire `OnChange` listeners. A listener that keeps per-process state will go stale on those replicas. Route the notification through the synced-settings refresh path, or document `OnChange` as single-instance, before the first listener is added.</violation>

<violation number="2" location="src/controller/config/controller.go:98">
P2: `NotifyChanged` runs before the config store publishes its update. Register it with `orm.AfterCommit` so listeners observe the committed value and are not notified for rolled-back transactions.</violation>
</file>

<file name="src/pkg/feature/feature.go">

<violation number="1" location="src/pkg/feature/feature.go:135">
P2: Custom agent: **Enforce Strict Maintainability Standards**

This compatibility path accepts an existing boolean item with the same environment key but a different `DefaultValue`, so the feature gate can fall back to `Definition.Default` while configuration metadata retains a conflicting default. Reject existing items whose default differs from the definition.</violation>

<violation number="2" location="src/pkg/feature/feature.go:318">
P2: The legacy env fallback only triggers when the new variable is absent. If `EnvKey` is set to an empty string, for example by a Helm or compose template, the feature is forced off and the legacy variable is ignored. Treat an empty value as unset before falling back to `LegacyEnvKey`.</violation>
</file>

<file name="api/v2.0/swagger.yaml">

<violation number="1" location="api/v2.0/swagger.yaml:342">
P3: `PUT /features/{feature_key}` returns 422 when the required `enabled` property is omitted, but this operation documents only 400/401/403/404/500. Add the shared 422 response so API clients can model validation failures.</violation>
</file>

<file name="src/portal/src/app/base/left-side-nav/config/features/features.component.ts">

<violation number="1" location="src/portal/src/app/base/left-side-nav/config/features/features.component.ts:104">
P2: Custom agent: **Enforce Strict Maintainability Standards**

Saving multiple toggles now sends one request per key, so earlier settings can be persisted while later ones are skipped when an update fails. Send the pending changes through one bulk update so the save is validated and applied as a unit.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic


func TestRegisterAfterInstanceIsBuilt(t *testing.T) {
Instance().init()
item := Item{Name: "registered_after_init", Scope: UserScope, Group: BasicGroup, DefaultValue: "true", ItemType: &BoolType{}, Editable: true}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test permanently mutates the package-global ConfigList, so repeated test runs in one process fail when the synthetic key is already registered. Snapshot ConfigList and restore it with t.Cleanup, then reinitialize the metadata singleton.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/lib/config/metadata/metadata_test.go, line 54:

<comment>This test permanently mutates the package-global `ConfigList`, so repeated test runs in one process fail when the synthetic key is already registered. Snapshot `ConfigList` and restore it with `t.Cleanup`, then reinitialize the metadata singleton.</comment>

<file context>
@@ -48,3 +48,23 @@ func TestCfgMetaData_Init(t *testing.T) {
+
+func TestRegisterAfterInstanceIsBuilt(t *testing.T) {
+	Instance().init()
+	item := Item{Name: "registered_after_init", Scope: UserScope, Group: BasicGroup, DefaultValue: "true", ItemType: &BoolType{}, Editable: true}
+
+	got, added := Register(item)
</file context>

ProjectCreationRestrict: utils.SafeCastString(cfg[common.ProjectCreationRestriction]),
RegistryStorageProviderName: utils.SafeCastString(cfg[common.RegistryStorageProviderName]),
NotificationEnable: utils.SafeCastBool(cfg[common.NotificationEnable]),
Features: enabledFeatures(ctx),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This breaks the existing TestGetInfo case: enabledFeatures always returns a non-nil empty map, but the test expects Features to be nil. Either set Features: map[string]bool{} in that expectation in controller_test.go, or return nil from enabledFeatures when there are no features.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/controller/systeminfo/controller.go, line 142:

<comment>This breaks the existing `TestGetInfo` case: `enabledFeatures` always returns a non-nil empty map, but the test expects `Features` to be nil. Either set `Features: map[string]bool{}` in that expectation in `controller_test.go`, or return nil from `enabledFeatures` when there are no features.</comment>

<file context>
@@ -137,10 +139,20 @@ func (c *controller) GetInfo(ctx context.Context, opt Options) (*Data, error) {
 		ProjectCreationRestrict:     utils.SafeCastString(cfg[common.ProjectCreationRestriction]),
 		RegistryStorageProviderName: utils.SafeCastString(cfg[common.RegistryStorageProviderName]),
 		NotificationEnable:          utils.SafeCastBool(cfg[common.NotificationEnable]),
+		Features:                    enabledFeatures(ctx),
 	}
 	return res, nil
</file context>

log.Errorf("failed to upload configurations: %v", err)
return fmt.Errorf("failed to validate configuration")
}
feature.NotifyChanged(conf)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: feature.NotifyChanged runs only on the replica that handled the request. Other replicas pick up the new setting through the Postgres LISTEN/NOTIFY sync but never fire OnChange listeners. A listener that keeps per-process state will go stale on those replicas. Route the notification through the synced-settings refresh path, or document OnChange as single-instance, before the first listener is added.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/controller/config/controller.go, line 98:

<comment>`feature.NotifyChanged` runs only on the replica that handled the request. Other replicas pick up the new setting through the Postgres LISTEN/NOTIFY sync but never fire `OnChange` listeners. A listener that keeps per-process state will go stale on those replicas. Route the notification through the synced-settings refresh path, or document `OnChange` as single-instance, before the first listener is added.</comment>

<file context>
@@ -95,6 +95,7 @@ func (c *controller) UpdateUserConfigs(ctx context.Context, conf map[string]any)
 		log.Errorf("failed to upload configurations: %v", err)
 		return fmt.Errorf("failed to validate configuration")
 	}
+	feature.NotifyChanged(conf)
 	// update the audit logger to point to the new endpoint
 	return c.updateLogEndpoint(ctx, conf)
</file context>

func environmentOverride(definition Definition) (enabled bool, envKey string, overridden bool) {
envKey = definition.EnvKey
value, exists := os.LookupEnv(envKey)
if !exists && definition.LegacyEnvKey != "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The legacy env fallback only triggers when the new variable is absent. If EnvKey is set to an empty string, for example by a Helm or compose template, the feature is forced off and the legacy variable is ignored. Treat an empty value as unset before falling back to LegacyEnvKey.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/pkg/feature/feature.go, line 318:

<comment>The legacy env fallback only triggers when the new variable is absent. If `EnvKey` is set to an empty string, for example by a Helm or compose template, the feature is forced off and the legacy variable is ignored. Treat an empty value as unset before falling back to `LegacyEnvKey`.</comment>

<file context>
@@ -133,22 +300,37 @@ func ConfigOverride(configKey string) (enabled bool, overridden bool) {
-	value, exists := os.LookupEnv(definition.EnvKey)
+	envKey = definition.EnvKey
+	value, exists := os.LookupEnv(envKey)
+	if !exists && definition.LegacyEnvKey != "" {
+		envKey = definition.LegacyEnvKey
+		value, exists = os.LookupEnv(envKey)
</file context>
Suggested change
if !exists && definition.LegacyEnvKey != "" {
if value == "" && definition.LegacyEnvKey != "" {

log.Errorf("failed to upload configurations: %v", err)
return fmt.Errorf("failed to validate configuration")
}
feature.NotifyChanged(conf)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: NotifyChanged runs before the config store publishes its update. Register it with orm.AfterCommit so listeners observe the committed value and are not notified for rolled-back transactions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/controller/config/controller.go, line 98:

<comment>`NotifyChanged` runs before the config store publishes its update. Register it with `orm.AfterCommit` so listeners observe the committed value and are not notified for rolled-back transactions.</comment>

<file context>
@@ -95,6 +95,7 @@ func (c *controller) UpdateUserConfigs(ctx context.Context, conf map[string]any)
 		log.Errorf("failed to upload configurations: %v", err)
 		return fmt.Errorf("failed to validate configuration")
 	}
+	feature.NotifyChanged(conf)
 	// update the audit logger to point to the new endpoint
 	return c.updateLogEndpoint(ctx, conf)
</file context>

this.onGoing = true;
// Sequential, so a rejected change (env lock, disable guard) stops
// the rest and the reload shows what was actually stored.
concat(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Enforce Strict Maintainability Standards

Saving multiple toggles now sends one request per key, so earlier settings can be persisted while later ones are skipped when an update fails. Send the pending changes through one bulk update so the save is validated and applied as a unit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/portal/src/app/base/left-side-nav/config/features/features.component.ts, line 104:

<comment>Saving multiple toggles now sends one request per key, so earlier settings can be persisted while later ones are skipped when an update fails. Send the pending changes through one bulk update so the save is validated and applied as a unit.</comment>

<file context>
@@ -12,109 +12,126 @@
-        });
+        // Sequential, so a rejected change (env lock, disable guard) stops
+        // the rest and the reload shows what was actually stored.
+        concat(
+            ...keys.map(key =>
+                this.configureService.updateFeature({
</file context>

if added {
return
}
if _, ok := item.ItemType.(*metadata.BoolType); !ok || item.EnvKey != def.EnvKey {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Enforce Strict Maintainability Standards

This compatibility path accepts an existing boolean item with the same environment key but a different DefaultValue, so the feature gate can fall back to Definition.Default while configuration metadata retains a conflicting default. Reject existing items whose default differs from the definition.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/pkg/feature/feature.go, line 135:

<comment>This compatibility path accepts an existing boolean item with the same environment key but a different `DefaultValue`, so the feature gate can fall back to `Definition.Default` while configuration metadata retains a conflicting default. Reject existing items whose default differs from the definition.</comment>

<file context>
@@ -12,64 +12,173 @@
+	if added {
+		return
+	}
+	if _, ok := item.ItemType.(*metadata.BoolType); !ok || item.EnvKey != def.EnvKey {
+		panic(fmt.Sprintf("config item %q exists with a different type or env key", def.ConfigKey))
+	}
</file context>

Comment thread api/v2.0/swagger.yaml
Comment on lines +342 to +343
'404':
$ref: '#/responses/404'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: PUT /features/{feature_key} returns 422 when the required enabled property is omitted, but this operation documents only 400/401/403/404/500. Add the shared 422 response so API clients can model validation failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At api/v2.0/swagger.yaml, line 342:

<comment>`PUT /features/{feature_key}` returns 422 when the required `enabled` property is omitted, but this operation documents only 400/401/403/404/500. Add the shared 422 response so API clients can model validation failures.</comment>

<file context>
@@ -289,6 +289,60 @@ paths:
+          $ref: '#/responses/401'
+        '403':
+          $ref: '#/responses/403'
+        '404':
+          $ref: '#/responses/404'
+        '500':
</file context>
Suggested change
'404':
$ref: '#/responses/404'
'404':
$ref: '#/responses/404'
'422':
$ref: '#/responses/422'

…res API

feature.Register now carries name, description, section, beta and default
and creates the boolean config item itself (metadata.Register works after
the config managers froze the item list). The Features page renders from
GET /api/v2.0/features and saves through PUT /api/v2.0/features/{key};
systeminfo reports a features map for UI gating. Adding a feature no
longer needs swagger fields, a metadata file or a portal entry.

RegisterSection adds page sections with a link resolved per request.
Resolve and OnChange expose effective values and change events, so an
OpenFeature provider can be added later without touching call sites.

Signed-off-by: Vad1mo <vadim@8gears.com>
Signed-off-by: Vad1mo <vadim@8gears.com>
@Vad1mo
Vad1mo force-pushed the feat/optional-features branch from d25bafb to 9eba68d Compare November 5, 2026 23:05

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/server/v2.0/handler/feature.go">

<violation number="1" location="src/server/v2.0/handler/feature.go:63">
P3: The name-only comparator leaves features with the same display name in registration order, which the comment above says should not matter. Add ConfigKey as a tie-breaker so the list is fully deterministic.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

// Registration order follows Go package initialization, which users
// should not see; sort by display name for a stable list.
definitions := feature.Definitions()
sort.SliceStable(definitions, func(i, j int) bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The name-only comparator leaves features with the same display name in registration order, which the comment above says should not matter. Add ConfigKey as a tie-breaker so the list is fully deterministic.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/server/v2.0/handler/feature.go, line 63:

<comment>The name-only comparator leaves features with the same display name in registration order, which the comment above says should not matter. Add ConfigKey as a tie-breaker so the list is fully deterministic.</comment>

<file context>
@@ -55,7 +57,12 @@ func (c *configAPI) UpdateFeature(ctx context.Context, params configure.UpdateFe
+	// Registration order follows Go package initialization, which users
+	// should not see; sort by display name for a stable list.
 	definitions := feature.Definitions()
+	sort.SliceStable(definitions, func(i, j int) bool {
+		return strings.ToLower(definitions[i].Name) < strings.ToLower(definitions[j].Name)
+	})
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant