From 2f54efe7c5ee18a1f3c4bb6401f287deb34b9138 Mon Sep 17 00:00:00 2001 From: Torben Kunow Date: Tue, 27 Oct 2026 14:30:27 +0100 Subject: [PATCH 01/11] Anhand der URL erkennen dass es sich um oci registry handelt --- frontend/src/app/registries/helm/helm-registry.ts | 3 +++ src/main/java/io/oneko/helm/HelmRegistry.java | 2 ++ src/main/java/io/oneko/helm/HelmRegistryMapper.java | 2 ++ src/main/java/io/oneko/helm/ReadableHelmRegistry.java | 6 ++++++ src/main/java/io/oneko/helm/WritableHelmRegistry.java | 6 ++++++ src/main/java/io/oneko/helm/rest/HelmRegistryDTO.java | 1 + 6 files changed, 20 insertions(+) diff --git a/frontend/src/app/registries/helm/helm-registry.ts b/frontend/src/app/registries/helm/helm-registry.ts index fa5f6801..1eab0bbb 100644 --- a/frontend/src/app/registries/helm/helm-registry.ts +++ b/frontend/src/app/registries/helm/helm-registry.ts @@ -4,6 +4,7 @@ export interface HelmRegistryDTO extends Registry { id: string url: string username: string + ociRegistry: boolean } export class HelmRegistry implements HelmRegistryDTO { @@ -11,6 +12,7 @@ export class HelmRegistry implements HelmRegistryDTO { name: string url: string username: string + ociRegistry: boolean public static from(from: HelmRegistryDTO): HelmRegistry { let registry = new HelmRegistry(); @@ -18,6 +20,7 @@ export class HelmRegistry implements HelmRegistryDTO { registry.name = from.name; registry.url = from.url; registry.username = from.username; + registry.ociRegistry = from.ociRegistry; return registry; } diff --git a/src/main/java/io/oneko/helm/HelmRegistry.java b/src/main/java/io/oneko/helm/HelmRegistry.java index 74bbacce..d3dd12bd 100644 --- a/src/main/java/io/oneko/helm/HelmRegistry.java +++ b/src/main/java/io/oneko/helm/HelmRegistry.java @@ -12,4 +12,6 @@ public interface HelmRegistry { String getUsername(); String getPassword(); + + boolean isOCIRegistry(); } diff --git a/src/main/java/io/oneko/helm/HelmRegistryMapper.java b/src/main/java/io/oneko/helm/HelmRegistryMapper.java index c38d7894..f77cf00c 100644 --- a/src/main/java/io/oneko/helm/HelmRegistryMapper.java +++ b/src/main/java/io/oneko/helm/HelmRegistryMapper.java @@ -2,6 +2,7 @@ import org.apache.commons.lang3.StringUtils; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import io.oneko.helm.rest.CreateHelmRegistryDTO; import io.oneko.helm.rest.HelmRegistryDTO; @@ -9,6 +10,7 @@ @Mapper(componentModel = "spring") public abstract class HelmRegistryMapper { + @Mapping(target = "ociRegistry", expression = "java(helmRegistry.isOCIRegistry())") public abstract HelmRegistryDTO toHelmRegistryDTO(HelmRegistry helmRegistry); public WritableHelmRegistry updateRegistryFromDTO(WritableHelmRegistry registry, HelmRegistryDTO dto) { diff --git a/src/main/java/io/oneko/helm/ReadableHelmRegistry.java b/src/main/java/io/oneko/helm/ReadableHelmRegistry.java index 529a9f6e..ef035d7b 100644 --- a/src/main/java/io/oneko/helm/ReadableHelmRegistry.java +++ b/src/main/java/io/oneko/helm/ReadableHelmRegistry.java @@ -5,6 +5,7 @@ import io.oneko.domain.Identifiable; import lombok.Builder; import lombok.Getter; +import org.apache.commons.lang3.StringUtils; @Getter @Builder @@ -24,4 +25,9 @@ public WritableHelmRegistry writable() { .password(getPassword()) .build(); } + + @Override + public boolean isOCIRegistry() { + return StringUtils.startsWith(this.url, "oci://"); + } } diff --git a/src/main/java/io/oneko/helm/WritableHelmRegistry.java b/src/main/java/io/oneko/helm/WritableHelmRegistry.java index c442beeb..a10eaa93 100644 --- a/src/main/java/io/oneko/helm/WritableHelmRegistry.java +++ b/src/main/java/io/oneko/helm/WritableHelmRegistry.java @@ -5,6 +5,7 @@ import io.oneko.domain.ModificationAwareIdentifiable; import io.oneko.domain.ModificationAwareProperty; import lombok.Builder; +import org.apache.commons.lang3.StringUtils; public class WritableHelmRegistry extends ModificationAwareIdentifiable implements HelmRegistry { @@ -78,6 +79,11 @@ public String getPassword() { return password.get(); } + @Override + public boolean isOCIRegistry() { + return StringUtils.startsWith(this.url.get(), "oci://"); + } + public void setPassword(String password) { this.password.set(password); } diff --git a/src/main/java/io/oneko/helm/rest/HelmRegistryDTO.java b/src/main/java/io/oneko/helm/rest/HelmRegistryDTO.java index acc0a61d..88a20910 100644 --- a/src/main/java/io/oneko/helm/rest/HelmRegistryDTO.java +++ b/src/main/java/io/oneko/helm/rest/HelmRegistryDTO.java @@ -10,4 +10,5 @@ public class HelmRegistryDTO { private String name; private String url; private String username; + private boolean ociRegistry; } From 8e89faa504a0cbd69c00c12dc0bf1e743c9263b8 Mon Sep 17 00:00:00 2001 From: Torben Kunow Date: Tue, 27 Oct 2026 22:50:24 +0100 Subject: [PATCH 02/11] bei oci registries helm registry login und logout aufrufen --- src/main/java/io/oneko/helm/HelmCommands.java | 20 ++++++++++++------ src/main/java/io/oneko/helmapi/api/Helm.java | 21 +++++++++++++++++++ .../java/io/oneko/helmapi/api/Helm3API.java | 4 ++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/oneko/helm/HelmCommands.java b/src/main/java/io/oneko/helm/HelmCommands.java index f9c3e007..27b9bb25 100644 --- a/src/main/java/io/oneko/helm/HelmCommands.java +++ b/src/main/java/io/oneko/helm/HelmCommands.java @@ -66,9 +66,13 @@ private Timer timer(String operation, MeterRegistry meterRegistry) { public void addRegistry(ReadableHelmRegistry helmRegistry) throws HelmRegistryException { try { - addRepoTimer.record(() -> - helm.addRepo(helmRegistry.getName(), helmRegistry.getUrl(), helmRegistry.getUsername(), helmRegistry.getPassword()) - ); + addRepoTimer.record(() -> { + if (helmRegistry.isOCIRegistry()) { + helm.loginToRegistry(helmRegistry.getUrl(), helmRegistry.getUsername(), helmRegistry.getPassword()); + } else { + helm.addRepo(helmRegistry.getName(), helmRegistry.getUrl(), helmRegistry.getUsername(), helmRegistry.getPassword()); + } + }); } catch (CommandException e) { commandErrorCounter.increment(); throw HelmRegistryException.fromCommandException(e, helmRegistry.getUrl(), helmRegistry.getName()); @@ -77,9 +81,13 @@ public void addRegistry(ReadableHelmRegistry helmRegistry) throws HelmRegistryEx public void deleteRegistry(ReadableHelmRegistry helmRegistry) throws HelmRegistryException { try { - deleteRepoTimer.record(() -> - helm.removeRepo(helmRegistry.getName()) - ); + deleteRepoTimer.record(() -> { + if (helmRegistry.isOCIRegistry()) { + helm.logoutFromRegistry(helmRegistry.getUrl()); + } else { + helm.removeRepo(helmRegistry.getName()); + } + }); } catch (CommandException e) { commandErrorCounter.increment(); throw HelmRegistryException.fromCommandException(e, helmRegistry.getUrl(), helmRegistry.getName()); diff --git a/src/main/java/io/oneko/helmapi/api/Helm.java b/src/main/java/io/oneko/helmapi/api/Helm.java index c79aefa5..cf16fc1e 100644 --- a/src/main/java/io/oneko/helmapi/api/Helm.java +++ b/src/main/java/io/oneko/helmapi/api/Helm.java @@ -114,6 +114,18 @@ public void addRepo(String name, String url, String username, String password, b log.info(out); } + @Override + public void loginToRegistry(String url, String username, String password) { + + final String[] command = initCommand("helm", "registry", "login") + .withArgument(url) + .withFlag("--username", username) + .withFlag("--password", password) + .build(); + var out = executor.execute(command); + log.info(out); + } + @Override public List listRepos() { return executor.executeWithJsonOutput(new TypeToken>() { @@ -129,6 +141,15 @@ public void removeRepo(String name) { log.info(out); } + @Override + public void logoutFromRegistry(String url) { + final String[] command = initCommand("helm", "registry", "logout") + .withArgument(url) + .build(); + var out = executor.execute(command); + log.info(out); + } + @Override public void updateRepos() { executor.execute("helm", "repo", "update"); diff --git a/src/main/java/io/oneko/helmapi/api/Helm3API.java b/src/main/java/io/oneko/helmapi/api/Helm3API.java index d17cfa54..1ed4745c 100644 --- a/src/main/java/io/oneko/helmapi/api/Helm3API.java +++ b/src/main/java/io/oneko/helmapi/api/Helm3API.java @@ -47,10 +47,14 @@ default void addRepo(String name, String url, String username, String password) void addRepo(String name, String url, String username, String password, boolean forceUpdate, boolean passCredentials); + void loginToRegistry(String url, String username, String password); + List listRepos(); void removeRepo(String name); + void logoutFromRegistry(String url); + void updateRepos(); // search From 0613a9f09311627cac4789250d1cc972752d55d0 Mon Sep 17 00:00:00 2001 From: Torben Kunow Date: Thu, 29 Oct 2026 17:32:50 +0100 Subject: [PATCH 03/11] radio button fuer OCI und klassische charts eingebaut --- .../helm-registry-edit-dialog.component.html | 29 +++++++++++-------- .../helm-registry-edit-dialog.component.ts | 17 +++++++++++ frontend/src/assets/i18n/de.json | 5 +++- frontend/src/assets/i18n/en.json | 5 +++- 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/registries/helm/edit-dialog/helm-registry-edit-dialog.component.html b/frontend/src/app/registries/helm/edit-dialog/helm-registry-edit-dialog.component.html index eeb68b73..b3293d94 100644 --- a/frontend/src/app/registries/helm/edit-dialog/helm-registry-edit-dialog.component.html +++ b/frontend/src/app/registries/helm/edit-dialog/helm-registry-edit-dialog.component.html @@ -3,18 +3,23 @@

{{(helmRegistry.isNew() ? 'components.helmRegistry.editDial
-
- - - {{'components.forms.errors.fieldIsRequired' | translate}} - - - - {{'components.forms.errors.fieldIsRequired' | translate}} - -
+ + + {{'components.forms.errors.fieldIsRequired' | translate}} + + + {{'components.helmRegistry.editDialog.classicRegistry' | translate}} + {{'components.helmRegistry.editDialog.ociRegistry' | translate}} + + + {{OCI_PREFIX}} + + {{'components.forms.errors.fieldIsRequired' | translate}} +
diff --git a/frontend/src/app/registries/helm/edit-dialog/helm-registry-edit-dialog.component.ts b/frontend/src/app/registries/helm/edit-dialog/helm-registry-edit-dialog.component.ts index 94129bbe..8b000930 100644 --- a/frontend/src/app/registries/helm/edit-dialog/helm-registry-edit-dialog.component.ts +++ b/frontend/src/app/registries/helm/edit-dialog/helm-registry-edit-dialog.component.ts @@ -20,6 +20,10 @@ export class HelmRegistryEditDialogComponent { public editingUser: User; public newPassword: string = ''; public passwordVerification: string = ''; + public registryType: 'oci' | 'classic'; + public registryUrl: string; + + public readonly OCI_PREFIX = 'oci://'; constructor(public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) private data: { registry: HelmRegistry, readOnly: boolean }, @@ -29,6 +33,15 @@ export class HelmRegistryEditDialogComponent { private translate: TranslateService) { this.userService.currentUser().subscribe(cu => this.editingUser = cu); this.helmRegistry = data ? HelmRegistry.from(data.registry) : new HelmRegistry(); + + const url = this.helmRegistry.url || ''; + if (url.startsWith(this.OCI_PREFIX)) { + this.registryType = 'oci'; + this.registryUrl = url.substring(this.OCI_PREFIX.length); + } else { + this.registryType = 'classic'; + this.registryUrl = url; + } } private _passwordFieldsVisible = false; @@ -70,4 +83,8 @@ export class HelmRegistryEditDialogComponent { public nameUpdated(): void { this.helmRegistry.name = this.helmRegistry.name.toLowerCase(); } + + public urlUpdated(): void { + this.helmRegistry.url = (this.registryType === 'oci' ? this.OCI_PREFIX : '') + this.registryUrl; + } } diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index 8ddcdec3..de4a81a6 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -140,7 +140,10 @@ "editDialog": { "createRegistry": "Helm Registry anlegen", "editRegistry": "Helm Registry bearbeiten", - "registryHasBeenModifiedByAction": "Die Helm Registry {registry} wurde {action, select, created{angelegt} deleted{gelöscht} saved{gespeichert} other{bearbeitet}}." + "registryHasBeenModifiedByAction": "Die Helm Registry {registry} wurde {action, select, created{angelegt} deleted{gelöscht} saved{gespeichert} other{bearbeitet}}.", + "registryType": "Registry-Typ", + "ociRegistry": "OCI Registry", + "classicRegistry": "Klassisches Repository" }, "registryUrl": "Registry URL", "helmRegistries": "!alias:menu.administration.helmRegistries", diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index fec6c0a4..103dffea 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -140,7 +140,10 @@ "editDialog": { "createRegistry": "Create Helm Registry", "editRegistry": "Edit Helm Registry", - "registryHasBeenModifiedByAction": "Helm Registry {registry} has been {action, select, created{created} deleted{deleted} saved{saved} other{modified}}." + "registryHasBeenModifiedByAction": "Helm Registry {registry} has been {action, select, created{created} deleted{deleted} saved{saved} other{modified}}.", + "registryType": "Registry Type", + "ociRegistry": "OCI Registry", + "classicRegistry": "Classic Repository" }, "registryUrl": "Registry URL", "helmRegistries": "!alias:menu.administration.helmRegistries", From 851903c33d65da4d58f3ef0e687b785c2e86f6cc Mon Sep 17 00:00:00 2001 From: Torben Kunow Date: Thu, 29 Oct 2026 17:55:58 +0100 Subject: [PATCH 04/11] kein float label fuer registry URL --- .../helm/edit-dialog/helm-registry-edit-dialog.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/registries/helm/edit-dialog/helm-registry-edit-dialog.component.html b/frontend/src/app/registries/helm/edit-dialog/helm-registry-edit-dialog.component.html index b3293d94..481bb8ae 100644 --- a/frontend/src/app/registries/helm/edit-dialog/helm-registry-edit-dialog.component.html +++ b/frontend/src/app/registries/helm/edit-dialog/helm-registry-edit-dialog.component.html @@ -14,7 +14,7 @@

{{(helmRegistry.isNew() ? 'components.helmRegistry.editDial {{'components.helmRegistry.editDialog.classicRegistry' | translate}} {{'components.helmRegistry.editDialog.ociRegistry' | translate}} - + {{OCI_PREFIX}} From ab860472dd204da1a78c1ab5c9e26cbc3fb29c9c Mon Sep 17 00:00:00 2001 From: Torben Kunow Date: Thu, 29 Oct 2026 19:12:54 +0100 Subject: [PATCH 05/11] oci von URL entfernen --- src/main/java/io/oneko/helmapi/api/Helm.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/oneko/helmapi/api/Helm.java b/src/main/java/io/oneko/helmapi/api/Helm.java index cf16fc1e..5a9995c8 100644 --- a/src/main/java/io/oneko/helmapi/api/Helm.java +++ b/src/main/java/io/oneko/helmapi/api/Helm.java @@ -118,9 +118,10 @@ public void addRepo(String name, String url, String username, String password, b public void loginToRegistry(String url, String username, String password) { final String[] command = initCommand("helm", "registry", "login") - .withArgument(url) + .withArgument(url.replaceFirst("^oci://", "")) .withFlag("--username", username) .withFlag("--password", password) + .withFlag("--plain-http", true) //TODO delete .build(); var out = executor.execute(command); log.info(out); From bfbc2e192ea2dfdb887330e997963d0723efc197 Mon Sep 17 00:00:00 2001 From: Torben Kunow Date: Fri, 30 Oct 2026 12:08:27 +0100 Subject: [PATCH 06/11] auch beim logout oci von url entfernen --- src/main/java/io/oneko/helmapi/api/Helm.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/oneko/helmapi/api/Helm.java b/src/main/java/io/oneko/helmapi/api/Helm.java index 5a9995c8..2dcf71eb 100644 --- a/src/main/java/io/oneko/helmapi/api/Helm.java +++ b/src/main/java/io/oneko/helmapi/api/Helm.java @@ -145,7 +145,7 @@ public void removeRepo(String name) { @Override public void logoutFromRegistry(String url) { final String[] command = initCommand("helm", "registry", "logout") - .withArgument(url) + .withArgument(url.replaceFirst("^oci://", "")) .build(); var out = executor.execute(command); log.info(out); From be64e69e5d4f423f5ce4092c1d7a5072a4892fca Mon Sep 17 00:00:00 2001 From: Torben Kunow Date: Fri, 30 Oct 2026 12:09:06 +0100 Subject: [PATCH 07/11] beim scannen der Charts Leere Liste setzen fuer OCI Charts --- src/main/java/io/oneko/helm/HelmCharts.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/oneko/helm/HelmCharts.java b/src/main/java/io/oneko/helm/HelmCharts.java index 49b4a10a..71d90988 100644 --- a/src/main/java/io/oneko/helm/HelmCharts.java +++ b/src/main/java/io/oneko/helm/HelmCharts.java @@ -33,10 +33,14 @@ public HelmCharts(HelmRegistryRepository helmRegistryRepository, HelmCommands he .build(registryId -> helmRegistryRepository.getById(registryId) .flatMap(helmRegistry -> { try { - List charts = helmCommands.getCharts(helmRegistry); - log.debug("found helm charts ({}, {})", kv("chart_count", charts.size()), helmRegistryKv(helmRegistry)); - - return toHelmChartDTO(helmRegistry, charts); + if (!helmRegistry.isOCIRegistry()) { + List charts = helmCommands.getCharts(helmRegistry); + log.debug("found helm charts ({}, {})", kv("chart_count", charts.size()), helmRegistryKv(helmRegistry)); + + return toHelmChartDTO(helmRegistry, charts); + } else { + return toHelmChartDTO(helmRegistry, List.of()); + } } catch (HelmRegistryException e) { return Optional.empty(); } From 81446ee2a281e939f1c044162180c908808a2d53 Mon Sep 17 00:00:00 2001 From: Torben Kunow Date: Mon, 2 Nov 2026 17:26:06 +0100 Subject: [PATCH 08/11] Claude: In _catalog endpoint nach helm charts suchen ansonsten ein freies Textfeld anbieten wo ein Helm Chart gesucht werden kann --- .../template-editor.component.ts | 53 +++- .../template-editor/template-editor.html | 11 +- frontend/src/app/rest/helm-rest.service.ts | 12 +- frontend/src/assets/i18n/de.json | 3 + frontend/src/assets/i18n/en.json | 3 + src/main/java/io/oneko/helm/HelmCharts.java | 29 +- .../io/oneko/helm/oci/HelmOciChartConfig.java | 18 ++ .../oci/OciHelmChartDiscoveryService.java | 53 ++++ .../oneko/helm/oci/OciHelmRegistryClient.java | 272 ++++++++++++++++++ .../helm/rest/HelmRegistryController.java | 15 + 10 files changed, 460 insertions(+), 9 deletions(-) create mode 100644 src/main/java/io/oneko/helm/oci/HelmOciChartConfig.java create mode 100644 src/main/java/io/oneko/helm/oci/OciHelmChartDiscoveryService.java create mode 100644 src/main/java/io/oneko/helm/oci/OciHelmRegistryClient.java diff --git a/frontend/src/app/deployable/template-editor/template-editor.component.ts b/frontend/src/app/deployable/template-editor/template-editor.component.ts index d65a227b..67f35b46 100644 --- a/frontend/src/app/deployable/template-editor/template-editor.component.ts +++ b/frontend/src/app/deployable/template-editor/template-editor.component.ts @@ -1,4 +1,5 @@ import {Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation} from '@angular/core'; +import {HttpErrorResponse} from '@angular/common/http'; import {UntypedFormControl, NG_VALUE_ACCESSOR} from '@angular/forms'; import {MatLegacyDialog as MatDialog} from '@angular/material/legacy-dialog'; import {MatLegacySnackBar as MatSnackBar} from '@angular/material/legacy-snack-bar'; @@ -89,9 +90,11 @@ export class TemplateEditorComponent implements OnInit { @Select(ThemingState.isDarkMode) isDarkTheme$: Observable; - public registryNameByRegistryId: {[name: string]: string} = {}; + public registryByRegistryId: {[id: string]: HelmRegistry} = {}; public chartRegistries: Observable>; public chartsByRegistry: {[registry: string]: HelmCharts} = {}; + public ociChartPathInput: string = ''; + public ociChartLookupError: string | null = null; public editorOptions: any = { theme: 'vs-light', @@ -114,6 +117,14 @@ export class TemplateEditorComponent implements OnInit { return this.chartsByRegistry[this.currentTemplateModel.effectiveTemplate.helmRegistryId]; } + public get currentRegistry(): HelmRegistry { + return this.registryByRegistryId[this.currentTemplateModel?.effectiveTemplate.helmRegistryId]; + } + + public get currentRegistryIsOci(): boolean { + return !!this.currentRegistry?.ociRegistry; + } + public get currentHelmChartNames(): Array { if (!this.currentHelmCharts || !this.currentHelmCharts.charts) { return []; @@ -168,16 +179,50 @@ export class TemplateEditorComponent implements OnInit { private loadHelmCharts() { this.chartRegistries.subscribe(registries => { registries.forEach(registry => { + this.registryByRegistryId[registry.getId()] = registry; this.rest.helm().getHelmChartsByRegistry(registry).subscribe(charts => { this.chartsByRegistry[registry.getId()] = charts; - this.registryNameByRegistryId[registry.getId()] = registry.name; }); }); }) } - public removeRegistryPart(chartName: string) { - return chartName.substr(this.registryNameByRegistryId[this.currentHelmCharts.registryId].length + 1, chartName.length); + public removeRegistryPart(chartName: string): string { + const parts = chartName.split('/'); + return parts[parts.length - 1]; + } + + /** + * Fallback for OCI registries whose catalog endpoint isn't supported (e.g. Docker Hub, GHCR, ECR) and therefore + * don't show up in `currentHelmChartNames`: looks up the versions of a single, already known chart by its path + * relative to the registry's URL. + */ + public lookupOciChart(): void { + const registry = this.currentRegistry; + const chartPath = this.ociChartPathInput?.trim(); + if (!registry || !chartPath) { + return; + } + + this.ociChartLookupError = null; + this.rest.helm().lookupOciChartVersions(registry, chartPath).subscribe({ + next: versions => { + if (!versions || !versions.length) { + this.ociChartLookupError = this.translate.instant('components.templateEditor.ociChartNotFound'); + return; + } + + const chartReference = `${registry.url.replace(/\/+$/, '')}/${chartPath}`; + const existingCharts = this.chartsByRegistry[registry.id] || {registryId: registry.id, charts: {}}; + this.chartsByRegistry[registry.id] = {...existingCharts, charts: {...existingCharts.charts, [chartReference]: versions}}; + + this.currentTemplateModel.effectiveTemplate.chartName = chartReference; + this.helmChartSettingsChanged(); + }, + error: (err: HttpErrorResponse) => { + this.ociChartLookupError = err.error?.message || this.translate.instant('components.templateEditor.ociChartNotFound'); + } + }); } public async onConfigUpload($event: FileList | File[]) { diff --git a/frontend/src/app/deployable/template-editor/template-editor.html b/frontend/src/app/deployable/template-editor/template-editor.html index 332e1f71..7c7627b8 100644 --- a/frontend/src/app/deployable/template-editor/template-editor.html +++ b/frontend/src/app/deployable/template-editor/template-editor.html @@ -58,12 +58,21 @@

{{label}}

{{ registry.name }}
- + {{'components.templateEditor.chartName' | translate}} {{ removeRegistryPart(chartName) }} + + {{'components.templateEditor.ociChartPath' | translate}} + + + {{currentTemplateModel.effectiveTemplate.chartName}} + {{ociChartLookupError}} + {{'components.templateEditor.chartVersionPlaceholder' | translate}} diff --git a/frontend/src/app/rest/helm-rest.service.ts b/frontend/src/app/rest/helm-rest.service.ts index 32a30932..8437dbb5 100644 --- a/frontend/src/app/rest/helm-rest.service.ts +++ b/frontend/src/app/rest/helm-rest.service.ts @@ -3,7 +3,7 @@ import {Observable} from "rxjs"; import {map} from "rxjs/operators"; import {HelmRegistry, HelmRegistryDTO} from "../registries/helm/helm-registry"; import {RegistryPasswordDto} from "../registries/registry-password-dto"; -import {HelmCharts} from "../registries/helm-charts"; +import {HelmCharts, HelmChartVersion} from "../registries/helm-charts"; export class HelmRestService { @@ -24,7 +24,7 @@ export class HelmRestService { public persistHelmRegistry(helmRegistry: HelmRegistry, password: string): Observable { if (helmRegistry.isNew()) { //create new helmRegistry call - return this.http.post(this.root_path, {...helmRegistry, password}).pipe(map(helmRegistryDTO => HelmRegistry.from(helmRegistryDTO)));; + return this.http.post(this.root_path, {...helmRegistry, password}).pipe(map(helmRegistryDTO => HelmRegistry.from(helmRegistryDTO))); } //update the existing project @@ -46,4 +46,12 @@ export class HelmRestService { public getHelmChartsByRegistry(helmRegistry: HelmRegistry): Observable { return this.http.get(`/${this.root_path}/${helmRegistry.id}/charts`); } + + /** + * Fallback for OCI registries that don't support chart discovery via their catalog endpoint (e.g. Docker Hub, + * GHCR, ECR): looks up the versions of a single chart whose path (relative to the registry's URL) is already known. + */ + public lookupOciChartVersions(helmRegistry: HelmRegistry, chartPath: string): Observable> { + return this.http.get>(`/${this.root_path}/${helmRegistry.id}/charts/lookup`, {params: {chartPath}}); + } } diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index de4a81a6..7face9a4 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -105,6 +105,9 @@ "overwritesInheritedConfiguration": "Das überschreibt eine geerbte Konfiguration.", "chartRegistry": "Chart Registry", "chartName": "Chartname", + "ociChartPath": "Chart-Pfad (relativ zur Registry-URL)", + "ociChartLookup": "Versionen suchen", + "ociChartNotFound": "Unter diesem Pfad wurde kein Helm Chart gefunden", "chartVersion": "Chartversion", "chartVersionPlaceholder": "Leer lassen für die neueste Version", "noTemplatesAvailable": "Kein Template vorhanden.", diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 103dffea..9b34dc67 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -105,6 +105,9 @@ "overwritesInheritedConfiguration": "This overwrites an inherited configuration.", "chartRegistry": "Chart registry", "chartName": "Chart name", + "ociChartPath": "Chart path (relative to registry URL)", + "ociChartLookup": "Look up versions", + "ociChartNotFound": "No helm chart found at this path", "chartVersion": "Chart version", "chartVersionPlaceholder": "Leave blank for latest version", "noTemplatesAvailable": "No configuration templates.", diff --git a/src/main/java/io/oneko/helm/HelmCharts.java b/src/main/java/io/oneko/helm/HelmCharts.java index 71d90988..1b09eea3 100644 --- a/src/main/java/io/oneko/helm/HelmCharts.java +++ b/src/main/java/io/oneko/helm/HelmCharts.java @@ -18,15 +18,21 @@ import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics; +import io.oneko.helm.oci.OciHelmChartDiscoveryService; import io.oneko.helmapi.model.Chart; import lombok.extern.slf4j.Slf4j; @Component @Slf4j public class HelmCharts { + private final HelmRegistryRepository helmRegistryRepository; + private final OciHelmChartDiscoveryService ociHelmChartDiscoveryService; private final LoadingCache chartsCache; - public HelmCharts(HelmRegistryRepository helmRegistryRepository, HelmCommands helmCommands, MeterRegistry meterRegistry) { + public HelmCharts(HelmRegistryRepository helmRegistryRepository, HelmCommands helmCommands, + OciHelmChartDiscoveryService ociHelmChartDiscoveryService, MeterRegistry meterRegistry) { + this.helmRegistryRepository = helmRegistryRepository; + this.ociHelmChartDiscoveryService = ociHelmChartDiscoveryService; this.chartsCache = Caffeine.newBuilder() .expireAfterWrite(3, TimeUnit.MINUTES) .recordStats() @@ -39,7 +45,10 @@ public HelmCharts(HelmRegistryRepository helmRegistryRepository, HelmCommands he return toHelmChartDTO(helmRegistry, charts); } else { - return toHelmChartDTO(helmRegistry, List.of()); + List charts = ociHelmChartDiscoveryService.discoverCharts(helmRegistry); + log.debug("found oci helm charts ({}, {})", kv("chart_count", charts.size()), helmRegistryKv(helmRegistry)); + + return toHelmChartDTO(helmRegistry, charts); } } catch (HelmRegistryException e) { return Optional.empty(); @@ -66,6 +75,22 @@ public void invalidateHelmChartsInRegistry(UUID registryId) { chartsCache.invalidate(registryId); } + /** + * Looks up the available versions of a single, known chart in an OCI registry by its path relative to the + * registry's URL. This is the fallback for OCI registries whose catalog endpoint isn't supported (and therefore + * doesn't show up via {@link #getChartsByHelmRegistry(UUID)}), e.g. Docker Hub, GHCR or ECR. + */ + public List lookupOciChartVersions(UUID registryId, String chartPath) throws HelmRegistryException { + ReadableHelmRegistry registry = helmRegistryRepository.getById(registryId) + .orElseThrow(() -> new HelmRegistryException("Helm registry " + registryId + " not found")); + if (!registry.isOCIRegistry()) { + throw new HelmRegistryException("Helm registry " + registry.getName() + " is not an OCI registry"); + } + return ociHelmChartDiscoveryService.lookupChart(registry, chartPath).stream() + .map(this::toHelmChartVersionDTO) + .collect(Collectors.toList()); + } + private Optional toHelmChartDTO(HelmRegistry helmRegistry, List charts) { if (charts.isEmpty()) { return Optional.empty(); diff --git a/src/main/java/io/oneko/helm/oci/HelmOciChartConfig.java b/src/main/java/io/oneko/helm/oci/HelmOciChartConfig.java new file mode 100644 index 00000000..05d75cc6 --- /dev/null +++ b/src/main/java/io/oneko/helm/oci/HelmOciChartConfig.java @@ -0,0 +1,18 @@ +package io.oneko.helm.oci; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Data; + +/** + * The config blob of a helm chart's OCI manifest is the chart's Chart.yaml serialized to JSON. + * See the Helm OCI documentation. + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class HelmOciChartConfig { + private String name; + private String version; + private String appVersion; + private String description; +} diff --git a/src/main/java/io/oneko/helm/oci/OciHelmChartDiscoveryService.java b/src/main/java/io/oneko/helm/oci/OciHelmChartDiscoveryService.java new file mode 100644 index 00000000..d67f2bf0 --- /dev/null +++ b/src/main/java/io/oneko/helm/oci/OciHelmChartDiscoveryService.java @@ -0,0 +1,53 @@ +package io.oneko.helm.oci; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.oneko.helm.HelmRegistry; +import io.oneko.helm.HelmRegistryException; +import io.oneko.helmapi.model.Chart; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +public class OciHelmChartDiscoveryService { + + private final ObjectMapper objectMapper; + + public OciHelmChartDiscoveryService(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Best-effort discovery of all helm charts in an OCI registry via the registry's (optional) catalog endpoint. + * Returns an empty list, rather than failing, for registries that do not support it - callers should offer + * {@link #lookupChart(HelmRegistry, String)} as a fallback for those in a UI. + */ + public List discoverCharts(HelmRegistry registry) { + OciHelmRegistryClient client = new OciHelmRegistryClient(registry, objectMapper); + List charts = new ArrayList<>(); + for (String repository : client.listChartRepositories()) { + try { + charts.addAll(client.listChartsIn(repository)); + } catch (HelmRegistryException e) { + log.warn("failed to list helm charts in OCI repository {} of registry {}", repository, registry.getName(), e); + } + } + return charts; + } + + /** + * Looks up the available versions of a single, already known chart, identified by its path relative to the + * registry's URL. Unlike {@link #discoverCharts(HelmRegistry)} this does not rely on the catalog endpoint and + * therefore also works against registries that don't support it (e.g. Docker Hub, GHCR, ECR). + */ + public List lookupChart(HelmRegistry registry, String chartPath) throws HelmRegistryException { + OciHelmRegistryClient client = new OciHelmRegistryClient(registry, objectMapper); + return client.listChartsIn(client.resolveRepositoryPath(chartPath)); + } + +} diff --git a/src/main/java/io/oneko/helm/oci/OciHelmRegistryClient.java b/src/main/java/io/oneko/helm/oci/OciHelmRegistryClient.java new file mode 100644 index 00000000..99b82896 --- /dev/null +++ b/src/main/java/io/oneko/helm/oci/OciHelmRegistryClient.java @@ -0,0 +1,272 @@ +package io.oneko.helm.oci; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import org.apache.http.Header; +import org.apache.http.HttpHeaders; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.oneko.docker.v2.AuthorizationHeader; +import io.oneko.helm.HelmRegistry; +import io.oneko.helm.HelmRegistryException; +import io.oneko.helmapi.model.Chart; +import lombok.extern.slf4j.Slf4j; + +/** + * Talks to the OCI distribution API (spec) of a helm OCI + * registry directly, since the helm CLI itself offers no way to discover charts in an OCI registry (unlike classic + * repos, which can be searched via their index.yaml). + */ +@Slf4j +public class OciHelmRegistryClient { + + private static final String HELM_CHART_CONFIG_MEDIA_TYPE = "application/vnd.cncf.helm.config.v1+json"; + private static final String MANIFEST_ACCEPT_HEADER = "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; + + private final String baseUrl; + private final String repositoryPathPrefix; + private final String username; + private final String password; + private final ObjectMapper objectMapper; + private final CloseableHttpClient httpClient = HttpClients.createDefault(); + private final Map authHeadersByScope = new HashMap<>(); + private String staticAuthHeader; + + public OciHelmRegistryClient(HelmRegistry registry, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.username = registry.getUsername(); + this.password = registry.getPassword(); + + String withoutScheme = StringUtils.removeStart(registry.getUrl(), "oci://"); + int slashIndex = withoutScheme.indexOf('/'); + String host = slashIndex < 0 ? withoutScheme : withoutScheme.substring(0, slashIndex); + this.repositoryPathPrefix = slashIndex < 0 ? "" : StringUtils.strip(withoutScheme.substring(slashIndex + 1), "/"); + this.baseUrl = determineBaseUrl(host); + } + + /** + * Prefers https, but falls back to plain http for registries that aren't reachable via TLS at all (e.g. local + * development registries), rather than failing outright. + */ + private String determineBaseUrl(String host) { + String httpsUrl = "https://" + host; + if (respondsOverHttp(httpsUrl)) { + return httpsUrl; + } + log.debug("OCI registry {} is not reachable via https, falling back to plain http", host); + return "http://" + host; + } + + private boolean respondsOverHttp(String candidateBaseUrl) { + try (CloseableHttpResponse response = httpClient.execute(new HttpGet(candidateBaseUrl + "/v2/"))) { + return true; + } catch (IOException e) { + return false; + } + } + + /** + * Combines a chart path given relative to this registry's URL (i.e. what a user would type after the configured + * registry URL to reference a chart) with this registry's own path prefix into a repository path relative to the + * registry host, as used by the rest of this client's methods. + */ + public String resolveRepositoryPath(String chartPath) { + String cleanedChartPath = StringUtils.strip(StringUtils.defaultString(chartPath), "/"); + if (repositoryPathPrefix.isEmpty()) { + return cleanedChartPath; + } + return cleanedChartPath.isEmpty() ? repositoryPathPrefix : repositoryPathPrefix + "/" + cleanedChartPath; + } + + /** + * Lists all repositories below this registry's configured path prefix via the (optional) catalog endpoint. + * Returns an empty list, rather than failing, if the registry does not support that endpoint (e.g. Docker Hub, + * GHCR and ECR do not implement it) - use {@link #resolveRepositoryPath(String)} with a known chart path instead + * in that case. + */ + public List listChartRepositories() { + Optional catalog = tryGet("/v2/_catalog?n=10000", "registry:catalog:*"); + if (catalog.isEmpty()) { + return List.of(); + } + List repositories = new ArrayList<>(); + catalog.get().path("repositories").forEach(node -> repositories.add(node.asText())); + + if (repositoryPathPrefix.isEmpty()) { + return repositories; + } + return repositories.stream() + .filter(repo -> repo.equals(repositoryPathPrefix) || repo.startsWith(repositoryPathPrefix + "/")) + .collect(Collectors.toList()); + } + + /** + * Lists all helm chart versions found in the given repository (a path relative to the registry host, as returned + * by {@link #listChartRepositories()} or {@link #resolveRepositoryPath(String)}). Tags that turn out not to be + * helm charts (e.g. container images sharing the same repository) are silently skipped. + */ + public List listChartsIn(String repositoryPath) throws HelmRegistryException { + String chartReference = "oci://" + StringUtils.removeStart(baseUrl, "https://") + "/" + repositoryPath; + List charts = new ArrayList<>(); + for (String tag : getTags(repositoryPath)) { + try { + getChartConfig(repositoryPath, tag) + .ifPresent(config -> charts.add(new Chart(chartReference, tag, config.getAppVersion(), config.getDescription()))); + } catch (HelmRegistryException e) { + log.warn("failed to inspect OCI artifact {}:{}, skipping it during helm chart discovery", repositoryPath, tag, e); + } + } + return charts; + } + + private List getTags(String repositoryPath) throws HelmRegistryException { + JsonNode result = get("/v2/" + repositoryPath + "/tags/list", "repository:" + repositoryPath + ":pull"); + List tags = new ArrayList<>(); + result.path("tags").forEach(node -> tags.add(node.asText())); + return tags; + } + + private Optional getChartConfig(String repositoryPath, String tag) throws HelmRegistryException { + String scope = "repository:" + repositoryPath + ":pull"; + JsonNode manifest = get("/v2/" + repositoryPath + "/manifests/" + tag, scope, MANIFEST_ACCEPT_HEADER); + JsonNode config = manifest.path("config"); + if (!HELM_CHART_CONFIG_MEDIA_TYPE.equals(config.path("mediaType").asText())) { + log.debug("skipping {}:{} during helm chart discovery, its manifest config media type is '{}' instead of '{}'", + repositoryPath, tag, config.path("mediaType").asText(), HELM_CHART_CONFIG_MEDIA_TYPE); + return Optional.empty(); + } + JsonNode configBlob = get("/v2/" + repositoryPath + "/blobs/" + config.path("digest").asText(), scope); + try { + return Optional.of(objectMapper.treeToValue(configBlob, HelmOciChartConfig.class)); + } catch (IOException e) { + throw new HelmRegistryException("failed to parse helm chart config of " + repositoryPath + ":" + tag, e); + } + } + + private Optional tryGet(String path, String scope) { + try { + return Optional.of(get(path, scope)); + } catch (HelmRegistryException e) { + log.debug("OCI registry {} does not support {}: {}", baseUrl, path, e.getMessage()); + return Optional.empty(); + } + } + + private JsonNode get(String path, String scope) throws HelmRegistryException { + return get(path, scope, "application/json"); + } + + private JsonNode get(String path, String scope, String accept) throws HelmRegistryException { + try { + String authHeader = staticAuthHeader != null ? staticAuthHeader : authHeadersByScope.get(scope); + try (CloseableHttpResponse response = executeGet(path, accept, authHeader)) { + if (response.getStatusLine().getStatusCode() == HttpStatus.SC_UNAUTHORIZED) { + String newAuthHeader = resolveAuthFromChallenge(response, scope); + if (newAuthHeader == null) { + throw toException(path, response); + } + try (CloseableHttpResponse retry = executeGet(path, accept, newAuthHeader)) { + return parseJsonOrThrow(path, retry); + } + } + return parseJsonOrThrow(path, response); + } + } catch (IOException e) { + throw new HelmRegistryException("failed to call OCI registry endpoint " + baseUrl + path + ": " + e.getMessage(), e); + } + } + + private CloseableHttpResponse executeGet(String path, String accept, String authorizationHeaderValue) throws IOException { + HttpGet request = new HttpGet(baseUrl + path); + request.addHeader(HttpHeaders.ACCEPT, accept); + if (authorizationHeaderValue != null) { + request.addHeader(AuthorizationHeader.KEY, authorizationHeaderValue); + } + return httpClient.execute(request); + } + + private JsonNode parseJsonOrThrow(String path, CloseableHttpResponse response) throws IOException, HelmRegistryException { + String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + int status = response.getStatusLine().getStatusCode(); + if (status < 200 || status >= 300) { + throw new HelmRegistryException("OCI registry endpoint " + baseUrl + path + " returned status " + status + ": " + body); + } + return objectMapper.readTree(body); + } + + private HelmRegistryException toException(String path, CloseableHttpResponse response) { + return new HelmRegistryException("OCI registry endpoint " + baseUrl + path + " requires authentication we could not satisfy (status " + + response.getStatusLine().getStatusCode() + ")"); + } + + /** + * Resolves an Authorization header value to retry a request with, based on the registry's WWW-Authenticate + * challenge. Supports the token based bearer auth flow described in + * the docker token authentication spec (used by + * most hosted registries), as well as plain Basic auth (used e.g. by a bare, htpasswd-protected + * registry:2 container, as is common for local development). + */ + private String resolveAuthFromChallenge(CloseableHttpResponse unauthorizedResponse, String scope) throws IOException { + Header[] authHeaders = unauthorizedResponse.getHeaders(HttpHeaders.WWW_AUTHENTICATE); + if (authHeaders.length == 0) { + return null; + } + String headerValue = authHeaders[0].getValue(); + + if (StringUtils.startsWithIgnoreCase(headerValue, "Basic")) { + if (StringUtils.isBlank(username)) { + return null; + } + staticAuthHeader = AuthorizationHeader.basic(username, password); + return staticAuthHeader; + } + + if (!StringUtils.startsWithIgnoreCase(headerValue, "Bearer")) { + return null; + } + String realm = StringUtils.substringBetween(headerValue, "realm=\"", "\""); + String service = StringUtils.substringBetween(headerValue, "service=\"", "\""); + if (realm == null) { + return null; + } + + StringBuilder tokenUrl = new StringBuilder(realm).append("?scope=").append(scope); + if (service != null) { + tokenUrl.append("&service=").append(service); + } + HttpGet tokenRequest = new HttpGet(tokenUrl.toString()); + if (StringUtils.isNotBlank(username)) { + tokenRequest.addHeader(AuthorizationHeader.KEY, AuthorizationHeader.basic(username, password)); + } + try (CloseableHttpResponse tokenResponse = httpClient.execute(tokenRequest)) { + if (tokenResponse.getStatusLine().getStatusCode() != HttpStatus.SC_OK) { + return null; + } + JsonNode json = objectMapper.readTree(EntityUtils.toString(tokenResponse.getEntity(), StandardCharsets.UTF_8)); + JsonNode tokenNode = json.has("token") ? json.get("token") : json.get("access_token"); + if (tokenNode == null) { + return null; + } + String authHeader = AuthorizationHeader.bearer(tokenNode.asText()); + authHeadersByScope.put(scope, authHeader); + return authHeader; + } + } + +} diff --git a/src/main/java/io/oneko/helm/rest/HelmRegistryController.java b/src/main/java/io/oneko/helm/rest/HelmRegistryController.java index fb4d23bb..a0a2081a 100644 --- a/src/main/java/io/oneko/helm/rest/HelmRegistryController.java +++ b/src/main/java/io/oneko/helm/rest/HelmRegistryController.java @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -134,6 +135,20 @@ HelmChartsDTO getCharts(@PathVariable UUID id) throws ResponseStatusException { .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No Helm charts in Helm registry with id " + id + " found.")); } + /** + * Fallback for OCI registries that don't support chart discovery via their catalog endpoint: looks up the + * versions of a single chart whose path (relative to the registry's URL) is already known. + */ + @PreAuthorize("hasAnyRole('ADMIN', 'DOER')") + @GetMapping("/{id}/charts/lookup") + List lookupOciChartVersions(@PathVariable UUID id, @RequestParam String chartPath) throws ResponseStatusException { + try { + return helmCharts.lookupOciChartVersions(id, chartPath); + } catch (HelmRegistryException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + private ReadableHelmRegistry getRegistryOr404(UUID id) { return this.helmRegistryRepository.getById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Helm Registry with id " + id + " not found.")); From b88c0de7fc8239dfdf6d78539f82e030aec74b72 Mon Sep 17 00:00:00 2001 From: Torben Kunow Date: Mon, 2 Nov 2026 18:50:01 +0100 Subject: [PATCH 09/11] freies eingabefeld hat ein icon das anzeigt ob ein helm chart gefunden wurde --- .../template-editor.component.ts | 74 +++++++++---------- .../template-editor/template-editor.html | 20 +++-- .../template-editor/template-editor.scss | 17 +++++ frontend/src/assets/i18n/de.json | 2 +- frontend/src/assets/i18n/en.json | 2 +- 5 files changed, 69 insertions(+), 46 deletions(-) diff --git a/frontend/src/app/deployable/template-editor/template-editor.component.ts b/frontend/src/app/deployable/template-editor/template-editor.component.ts index 67f35b46..07ed5ef8 100644 --- a/frontend/src/app/deployable/template-editor/template-editor.component.ts +++ b/frontend/src/app/deployable/template-editor/template-editor.component.ts @@ -1,5 +1,4 @@ import {Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation} from '@angular/core'; -import {HttpErrorResponse} from '@angular/common/http'; import {UntypedFormControl, NG_VALUE_ACCESSOR} from '@angular/forms'; import {MatLegacyDialog as MatDialog} from '@angular/material/legacy-dialog'; import {MatLegacySnackBar as MatSnackBar} from '@angular/material/legacy-snack-bar'; @@ -9,7 +8,8 @@ import {ConfigurationTemplate} from '../configuration-template'; import {EditConfigurationTemplateDialogComponent} from './edit-configuration-template-dialog/edit-configuration-template-dialog.component'; import {Select, Store} from "@ngxs/store"; import {ThemingState} from "../../store/theming/theming.state"; -import {Observable} from "rxjs"; +import {Observable, of} from "rxjs"; +import {catchError, debounceTime, distinctUntilChanged, map, switchMap} from "rxjs/operators"; import {FileDownloadService} from '../../util/file-download.service'; import {TranslateService} from "@ngx-translate/core"; import {RestService} from "../../rest/rest.service"; @@ -93,8 +93,9 @@ export class TemplateEditorComponent implements OnInit { public registryByRegistryId: {[id: string]: HelmRegistry} = {}; public chartRegistries: Observable>; public chartsByRegistry: {[registry: string]: HelmCharts} = {}; - public ociChartPathInput: string = ''; - public ociChartLookupError: string | null = null; + public ociChartPathControl = new UntypedFormControl(''); + public ociChartLookupStatus: 'found' | 'not-found' = 'not-found'; + public chartNameFocused: boolean; public editorOptions: any = { theme: 'vs-light', @@ -152,6 +153,38 @@ export class TemplateEditorComponent implements OnInit { }; }); this.chartRegistries = this.rest.helm().getAllHelmRegistries(); + + this.ociChartPathControl.valueChanges.pipe( + debounceTime(500), + distinctUntilChanged(), + switchMap((chartPath: string) => { + const registry = this.currentRegistry; + const trimmedChartPath = chartPath?.trim(); + if (!registry || !trimmedChartPath) { + return of(null); + } + + return this.rest.helm().lookupOciChartVersions(registry, trimmedChartPath).pipe( + map(versions => ({registry, chartPath: trimmedChartPath, versions})), + catchError(() => of({registry, chartPath: trimmedChartPath, versions: null})) + ); + }) + ).subscribe(result => { + if (!result || !result.versions?.length) { + if (result) { + this.ociChartLookupStatus = 'not-found'; + } + return; + } + + this.ociChartLookupStatus = 'found'; + const chartReference = `${result.registry.url.replace(/\/+$/, '')}/${result.chartPath}`; // strip trailing slashes + const existingCharts = this.chartsByRegistry[result.registry.id] || {registryId: result.registry.id, charts: {}}; + this.chartsByRegistry[result.registry.id] = {...existingCharts, charts: {...existingCharts.charts, [chartReference]: result.versions}}; + + this.currentTemplateModel.effectiveTemplate.chartName = chartReference; + this.helmChartSettingsChanged(); + }); } ngOnInit(): void { @@ -192,39 +225,6 @@ export class TemplateEditorComponent implements OnInit { return parts[parts.length - 1]; } - /** - * Fallback for OCI registries whose catalog endpoint isn't supported (e.g. Docker Hub, GHCR, ECR) and therefore - * don't show up in `currentHelmChartNames`: looks up the versions of a single, already known chart by its path - * relative to the registry's URL. - */ - public lookupOciChart(): void { - const registry = this.currentRegistry; - const chartPath = this.ociChartPathInput?.trim(); - if (!registry || !chartPath) { - return; - } - - this.ociChartLookupError = null; - this.rest.helm().lookupOciChartVersions(registry, chartPath).subscribe({ - next: versions => { - if (!versions || !versions.length) { - this.ociChartLookupError = this.translate.instant('components.templateEditor.ociChartNotFound'); - return; - } - - const chartReference = `${registry.url.replace(/\/+$/, '')}/${chartPath}`; - const existingCharts = this.chartsByRegistry[registry.id] || {registryId: registry.id, charts: {}}; - this.chartsByRegistry[registry.id] = {...existingCharts, charts: {...existingCharts.charts, [chartReference]: versions}}; - - this.currentTemplateModel.effectiveTemplate.chartName = chartReference; - this.helmChartSettingsChanged(); - }, - error: (err: HttpErrorResponse) => { - this.ociChartLookupError = err.error?.message || this.translate.instant('components.templateEditor.ociChartNotFound'); - } - }); - } - public async onConfigUpload($event: FileList | File[]) { const files = await this._fileReaderService.readAll($event); diff --git a/frontend/src/app/deployable/template-editor/template-editor.html b/frontend/src/app/deployable/template-editor/template-editor.html index 7c7627b8..50b284f4 100644 --- a/frontend/src/app/deployable/template-editor/template-editor.html +++ b/frontend/src/app/deployable/template-editor/template-editor.html @@ -58,20 +58,26 @@

{{label}}

{{ registry.name }}
- + {{'components.templateEditor.chartName' | translate}} {{ removeRegistryPart(chartName) }} - + {{'components.templateEditor.ociChartPath' | translate}} - - + +
+ + + + +
{{currentTemplateModel.effectiveTemplate.chartName}} - {{ociChartLookupError}}
{{'components.templateEditor.chartVersionPlaceholder' | translate}} diff --git a/frontend/src/app/deployable/template-editor/template-editor.scss b/frontend/src/app/deployable/template-editor/template-editor.scss index 6a821941..6a5bcdda 100644 --- a/frontend/src/app/deployable/template-editor/template-editor.scss +++ b/frontend/src/app/deployable/template-editor/template-editor.scss @@ -20,6 +20,23 @@ .helm-chart-controls { margin: 8px auto; + + .oci-chart-path-field .mat-form-field-suffix { + align-self: flex-end; + } + + .oci-chart-status { + width: 24px; + height: 24px; + } + + .oci-chart-found-icon { + color: #4caf50; + } + + .oci-chart-not-found-icon { + color: #f44336; + } } .code-editor-wrapper { diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index 7face9a4..8e33ea92 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -106,7 +106,7 @@ "chartRegistry": "Chart Registry", "chartName": "Chartname", "ociChartPath": "Chart-Pfad (relativ zur Registry-URL)", - "ociChartLookup": "Versionen suchen", + "ociChartFound": "Helm Chart unter diesem Pfad gefunden", "ociChartNotFound": "Unter diesem Pfad wurde kein Helm Chart gefunden", "chartVersion": "Chartversion", "chartVersionPlaceholder": "Leer lassen für die neueste Version", diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 9b34dc67..4d660422 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -106,7 +106,7 @@ "chartRegistry": "Chart registry", "chartName": "Chart name", "ociChartPath": "Chart path (relative to registry URL)", - "ociChartLookup": "Look up versions", + "ociChartFound": "Helm chart found at this path", "ociChartNotFound": "No helm chart found at this path", "chartVersion": "Chart version", "chartVersionPlaceholder": "Leave blank for latest version", From 32cfd73b1a950e616cef8493ed68eedfb0a0996b Mon Sep 17 00:00:00 2001 From: Torben Kunow Date: Tue, 3 Nov 2026 09:30:16 +0100 Subject: [PATCH 10/11] helm repo update wird nur ausgefuehrt falls es ein template gibt dass kein oci repo benutzt --- src/main/java/io/oneko/helm/HelmCommands.java | 17 ++++++++++++++--- .../java/io/oneko/helm/HelmCommandsTest.java | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/oneko/helm/HelmCommands.java b/src/main/java/io/oneko/helm/HelmCommands.java index 27b9bb25..c93b0001 100644 --- a/src/main/java/io/oneko/helm/HelmCommands.java +++ b/src/main/java/io/oneko/helm/HelmCommands.java @@ -42,8 +42,10 @@ public class HelmCommands { private Instant lastRepoUpdate = Instant.MIN; + private final HelmRegistryRepository helmRegistryRepository; - public HelmCommands(MeterRegistry meterRegistry) { + public HelmCommands(MeterRegistry meterRegistry, HelmRegistryRepository helmRegistryRepository) { + this.helmRegistryRepository = helmRegistryRepository; this.commandErrorCounter = Counter.builder(new MetricNameBuilder().amountOf("helm.command.errors").build()) .description("number of errors during Helm command execution") .register(meterRegistry); @@ -105,6 +107,10 @@ public synchronized void updateReposNotTooOften() { } public List getCharts(ReadableHelmRegistry helmRegistry) throws HelmRegistryException { + if (helmRegistry.isOCIRegistry()) { + return List.of(); + } + try { updateReposNotTooOften(); return searchRepoTimer.record(() -> @@ -118,9 +124,14 @@ public List getCharts(ReadableHelmRegistry helmRegistry) throws HelmRegis public List install(ProjectVersion projectVersion, boolean wait) throws HelmRegistryException { try { - updateReposNotTooOften(); - final var sample = Timer.start(); List calculatedConfigurationTemplates = projectVersion.getCalculatedConfigurationTemplates(); + boolean usesNonOCIRegistry = calculatedConfigurationTemplates.stream() + .map(template -> helmRegistryRepository.getById(template.getHelmRegistryId())) + .anyMatch(registry -> registry.map(r -> !r.isOCIRegistry()).orElse(false)); + if (usesNonOCIRegistry) { + updateReposNotTooOften(); + } + final var sample = Timer.start(); List result = new ArrayList<>(); for (int i = 0; i < calculatedConfigurationTemplates.size(); i++) { WritableConfigurationTemplate template = calculatedConfigurationTemplates.get(i); diff --git a/src/test/java/io/oneko/helm/HelmCommandsTest.java b/src/test/java/io/oneko/helm/HelmCommandsTest.java index 2e7457b4..be776bde 100644 --- a/src/test/java/io/oneko/helm/HelmCommandsTest.java +++ b/src/test/java/io/oneko/helm/HelmCommandsTest.java @@ -13,7 +13,7 @@ class HelmCommandsTest { - private HelmCommands uut = new HelmCommands(new SimpleMeterRegistry()); + private final HelmCommands uut = new HelmCommands(new SimpleMeterRegistry(), mock(HelmRegistryRepository.class)); @Test void getLongReleaseNamePrefix() { From 21d3e746dea494480d3cbcc64bbb71f129782799 Mon Sep 17 00:00:00 2001 From: Torben Kunow Date: Tue, 3 Nov 2026 15:26:40 +0100 Subject: [PATCH 11/11] --plain-http unterstuetzen + sachen die aus dem helm install vor dem json kommen herausfiltern --- src/main/java/io/oneko/helm/HelmCommands.java | 7 +- .../oneko/helm/oci/OciHelmRegistryClient.java | 34 ++------ src/main/java/io/oneko/helmapi/api/Helm.java | 20 ++--- .../java/io/oneko/helmapi/api/Helm3API.java | 6 +- .../io/oneko/helmapi/api/OciRegistryUrl.java | 81 +++++++++++++++++++ .../process/DelegatingCommandExecutor.java | 32 +++++++- .../java/io/oneko/helmapi/api/HelmTest.java | 27 +++++++ .../oneko/helmapi/api/OciRegistryUrlTest.java | 45 +++++++++++ .../DelegatingCommandExecutorTest.java | 43 ++++++++++ 9 files changed, 253 insertions(+), 42 deletions(-) create mode 100644 src/main/java/io/oneko/helmapi/api/OciRegistryUrl.java create mode 100644 src/test/java/io/oneko/helmapi/api/OciRegistryUrlTest.java create mode 100644 src/test/java/io/oneko/helmapi/process/DelegatingCommandExecutorTest.java diff --git a/src/main/java/io/oneko/helm/HelmCommands.java b/src/main/java/io/oneko/helm/HelmCommands.java index c93b0001..07ff0e1c 100644 --- a/src/main/java/io/oneko/helm/HelmCommands.java +++ b/src/main/java/io/oneko/helm/HelmCommands.java @@ -14,6 +14,7 @@ import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import io.oneko.helmapi.api.Helm; +import io.oneko.helmapi.api.OciRegistryUrl; import io.oneko.helmapi.model.Chart; import io.oneko.helmapi.model.InstallStatus; import io.oneko.helmapi.model.Release; @@ -135,7 +136,11 @@ public List install(ProjectVersion projectVersion, boolean List result = new ArrayList<>(); for (int i = 0; i < calculatedConfigurationTemplates.size(); i++) { WritableConfigurationTemplate template = calculatedConfigurationTemplates.get(i); - result.add(helm.install(getReleaseName(projectVersion, i), template.getChartName(), template.getChartVersion(), Values.fromYamlString(template.getContent()), projectVersion.getNamespaceOrElseFromProject(), false, wait)); + boolean plainHttp = helmRegistryRepository.getById(template.getHelmRegistryId()) + .filter(HelmRegistry::isOCIRegistry) + .map(registry -> OciRegistryUrl.resolve(registry.getUrl()).plainHttp()) + .orElse(false); + result.add(helm.install(getReleaseName(projectVersion, i), template.getChartName(), template.getChartVersion(), Values.fromYamlString(template.getContent()), projectVersion.getNamespaceOrElseFromProject(), false, wait, plainHttp)); } sample.stop(installTimer); return result; diff --git a/src/main/java/io/oneko/helm/oci/OciHelmRegistryClient.java b/src/main/java/io/oneko/helm/oci/OciHelmRegistryClient.java index 99b82896..7c081b2f 100644 --- a/src/main/java/io/oneko/helm/oci/OciHelmRegistryClient.java +++ b/src/main/java/io/oneko/helm/oci/OciHelmRegistryClient.java @@ -25,6 +25,7 @@ import io.oneko.docker.v2.AuthorizationHeader; import io.oneko.helm.HelmRegistry; import io.oneko.helm.HelmRegistryException; +import io.oneko.helmapi.api.OciRegistryUrl; import io.oneko.helmapi.model.Chart; import lombok.extern.slf4j.Slf4j; @@ -39,6 +40,7 @@ public class OciHelmRegistryClient { private static final String HELM_CHART_CONFIG_MEDIA_TYPE = "application/vnd.cncf.helm.config.v1+json"; private static final String MANIFEST_ACCEPT_HEADER = "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; + private final String host; private final String baseUrl; private final String repositoryPathPrefix; private final String username; @@ -53,32 +55,10 @@ public OciHelmRegistryClient(HelmRegistry registry, ObjectMapper objectMapper) { this.username = registry.getUsername(); this.password = registry.getPassword(); - String withoutScheme = StringUtils.removeStart(registry.getUrl(), "oci://"); - int slashIndex = withoutScheme.indexOf('/'); - String host = slashIndex < 0 ? withoutScheme : withoutScheme.substring(0, slashIndex); - this.repositoryPathPrefix = slashIndex < 0 ? "" : StringUtils.strip(withoutScheme.substring(slashIndex + 1), "/"); - this.baseUrl = determineBaseUrl(host); - } - - /** - * Prefers https, but falls back to plain http for registries that aren't reachable via TLS at all (e.g. local - * development registries), rather than failing outright. - */ - private String determineBaseUrl(String host) { - String httpsUrl = "https://" + host; - if (respondsOverHttp(httpsUrl)) { - return httpsUrl; - } - log.debug("OCI registry {} is not reachable via https, falling back to plain http", host); - return "http://" + host; - } - - private boolean respondsOverHttp(String candidateBaseUrl) { - try (CloseableHttpResponse response = httpClient.execute(new HttpGet(candidateBaseUrl + "/v2/"))) { - return true; - } catch (IOException e) { - return false; - } + OciRegistryUrl registryUrl = OciRegistryUrl.resolve(registry.getUrl()); + this.host = registryUrl.host(); + this.repositoryPathPrefix = registryUrl.repositoryPathPrefix(); + this.baseUrl = (registryUrl.plainHttp() ? "http://" : "https://") + host; } /** @@ -122,7 +102,7 @@ public List listChartRepositories() { * helm charts (e.g. container images sharing the same repository) are silently skipped. */ public List listChartsIn(String repositoryPath) throws HelmRegistryException { - String chartReference = "oci://" + StringUtils.removeStart(baseUrl, "https://") + "/" + repositoryPath; + String chartReference = "oci://" + host + "/" + repositoryPath; List charts = new ArrayList<>(); for (String tag : getTags(repositoryPath)) { try { diff --git a/src/main/java/io/oneko/helmapi/api/Helm.java b/src/main/java/io/oneko/helmapi/api/Helm.java index 2dcf71eb..5d86a594 100644 --- a/src/main/java/io/oneko/helmapi/api/Helm.java +++ b/src/main/java/io/oneko/helmapi/api/Helm.java @@ -62,8 +62,8 @@ public String version() { * @return */ @Override - public InstallStatus install(String name, String chart, String version, Values values, String namespace, boolean dryRun, boolean wait) { - final String[] command = initCommand("helm", "install") + public InstallStatus install(String name, String chart, String version, Values values, String namespace, boolean dryRun, boolean wait, boolean plainHttp) { + Command commandBuilder = initCommand("helm", "install") .withArgument(name) .withArgument(chart) .withFlag("--version", version) @@ -71,9 +71,11 @@ public InstallStatus install(String name, String chart, String version, Values v .withFlag("-f", values.getValuesFilePath().orElse(writeValuesToTemporaryFile(values, name).getAbsolutePath())) .withFlag("--dry-run", dryRun) .withFlag("--wait", wait) - .withFlag("-o", "json") - .build(); - return executor.executeWithJsonOutput(InstallStatus.class, command); + .withFlag("-o", "json"); + if (StringUtils.startsWith(chart, "oci://")) { + commandBuilder.withFlag("--plain-http", plainHttp); + } + return executor.executeWithJsonOutput(InstallStatus.class, commandBuilder.build()); } @Override @@ -116,12 +118,12 @@ public void addRepo(String name, String url, String username, String password, b @Override public void loginToRegistry(String url, String username, String password) { - + OciRegistryUrl registryUrl = OciRegistryUrl.resolve(url); final String[] command = initCommand("helm", "registry", "login") - .withArgument(url.replaceFirst("^oci://", "")) + .withArgument(registryUrl.getHostAndPath()) .withFlag("--username", username) .withFlag("--password", password) - .withFlag("--plain-http", true) //TODO delete + .withFlag("--plain-http", registryUrl.plainHttp()) .build(); var out = executor.execute(command); log.info(out); @@ -145,7 +147,7 @@ public void removeRepo(String name) { @Override public void logoutFromRegistry(String url) { final String[] command = initCommand("helm", "registry", "logout") - .withArgument(url.replaceFirst("^oci://", "")) + .withArgument(OciRegistryUrl.hostAndPath(url)) .build(); var out = executor.execute(command); log.info(out); diff --git a/src/main/java/io/oneko/helmapi/api/Helm3API.java b/src/main/java/io/oneko/helmapi/api/Helm3API.java index 1ed4745c..39c3c85f 100644 --- a/src/main/java/io/oneko/helmapi/api/Helm3API.java +++ b/src/main/java/io/oneko/helmapi/api/Helm3API.java @@ -18,14 +18,14 @@ default InstallStatus install(String name, String chart, Values values) { } default InstallStatus install(String name, String chart, Values values, String namespace) { - return install(name, chart, null, values, namespace, false, false); + return install(name, chart, null, values, namespace, false, false, false); } default InstallStatus install(String name, File pathToChartDirectory, Values values, String namespace, boolean dryRun) { - return install(name, pathToChartDirectory.getAbsolutePath(), null, values, namespace, dryRun, false); + return install(name, pathToChartDirectory.getAbsolutePath(), null, values, namespace, dryRun, false, false); } - InstallStatus install(String name, String chart, String version, Values values, String namespace, boolean dryRun, boolean wait); + InstallStatus install(String name, String chart, String version, Values values, String namespace, boolean dryRun, boolean wait, boolean plainHttp); // list default List list() { diff --git a/src/main/java/io/oneko/helmapi/api/OciRegistryUrl.java b/src/main/java/io/oneko/helmapi/api/OciRegistryUrl.java new file mode 100644 index 00000000..cd01878d --- /dev/null +++ b/src/main/java/io/oneko/helmapi/api/OciRegistryUrl.java @@ -0,0 +1,81 @@ +package io.oneko.helmapi.api; + +import java.io.IOException; + +import org.apache.commons.lang3.StringUtils; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; + +import lombok.extern.slf4j.Slf4j; + +/** + * Parses an OCI registry URL as stored on a helm registry (e.g. {@code oci://host/path}, optionally with an explicit + * {@code oci://http://host/path} or {@code oci://https://host/path} scheme) into the bare host, the repository path + * prefix below it, and whether the registry should be talked to via plain HTTP. + *

+ * If no scheme is given explicitly, the registry is probed (https first, falling back to http) rather than failing + * outright, to support registries that aren't reachable via TLS at all (e.g. local development registries). + */ +@Slf4j +public record OciRegistryUrl(String host, String repositoryPathPrefix, boolean plainHttp) { + + private static final String OCI_SCHEME = "oci://"; + private static final String HTTP_SCHEME = "http://"; + private static final String HTTPS_SCHEME = "https://"; + + public static OciRegistryUrl resolve(String url) { + Parsed parsed = parse(url); + boolean plainHttp = parsed.explicitPlainHttp != null ? parsed.explicitPlainHttp : !respondsOverHttps(parsed.host); + return new OciRegistryUrl(parsed.host, parsed.repositoryPathPrefix, plainHttp); + } + + /** + * Strips the {@code oci://} scheme (and, if present, an explicit {@code http://}/{@code https://} scheme below + * it) without probing the registry. Use this where only the bare host/path is needed and plain-http-ness is + * irrelevant, to avoid an unnecessary network call. + */ + public static String hostAndPath(String url) { + Parsed parsed = parse(url); + return parsed.repositoryPathPrefix.isEmpty() ? parsed.host : parsed.host + "/" + parsed.repositoryPathPrefix; + } + + public String getHostAndPath() { + return repositoryPathPrefix.isEmpty() ? host : host + "/" + repositoryPathPrefix; + } + + private static Parsed parse(String url) { + String withoutOciScheme = StringUtils.removeStart(StringUtils.defaultString(url), OCI_SCHEME); + + Boolean explicitPlainHttp = null; + if (StringUtils.startsWithIgnoreCase(withoutOciScheme, HTTP_SCHEME)) { + explicitPlainHttp = true; + withoutOciScheme = withoutOciScheme.substring(HTTP_SCHEME.length()); + } else if (StringUtils.startsWithIgnoreCase(withoutOciScheme, HTTPS_SCHEME)) { + explicitPlainHttp = false; + withoutOciScheme = withoutOciScheme.substring(HTTPS_SCHEME.length()); + } + + int slashIndex = withoutOciScheme.indexOf('/'); + String host = slashIndex < 0 ? withoutOciScheme : withoutOciScheme.substring(0, slashIndex); + String repositoryPathPrefix = slashIndex < 0 ? "" : StringUtils.strip(withoutOciScheme.substring(slashIndex + 1), "/"); + + return new Parsed(host, repositoryPathPrefix, explicitPlainHttp); + } + + private static boolean respondsOverHttps(String host) { + String httpsUrl = "https://" + host + "/v2/"; + try (CloseableHttpClient httpClient = HttpClients.createDefault(); + CloseableHttpResponse ignored = httpClient.execute(new HttpGet(httpsUrl))) { + return true; + } catch (IOException e) { + log.debug("OCI registry {} is not reachable via https, falling back to plain http", host); + return false; + } + } + + private record Parsed(String host, String repositoryPathPrefix, Boolean explicitPlainHttp) { + + } +} diff --git a/src/main/java/io/oneko/helmapi/process/DelegatingCommandExecutor.java b/src/main/java/io/oneko/helmapi/process/DelegatingCommandExecutor.java index a05499e5..5955b867 100644 --- a/src/main/java/io/oneko/helmapi/process/DelegatingCommandExecutor.java +++ b/src/main/java/io/oneko/helmapi/process/DelegatingCommandExecutor.java @@ -1,8 +1,10 @@ package io.oneko.helmapi.process; import java.lang.reflect.Type; +import java.util.Arrays; import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; public class DelegatingCommandExecutor { private final Gson gson = new Gson(); @@ -18,12 +20,38 @@ public String execute(String... command) { public T executeWithJsonOutput(Class returnType, String... command) { final String rawResult = delegate.doExecute(command); - return gson.fromJson(rawResult, returnType); + return parseJson(rawResult, returnType, command); } public T executeWithJsonOutput(Type returnType, String... command) { final String rawResult = delegate.doExecute(command); - return gson.fromJson(rawResult, returnType); + return parseJson(rawResult, returnType, command); + } + + /** + * The underlying process can exit successfully while still printing informational text before the actual JSON on + * stdout - e.g. helm writes "Pulled: ..."/"Digest: ..." progress lines there (rather than stderr) for OCI charts, + * depending on the helm version/platform. The JSON payload is always the last thing printed, so anything before + * the first '{' or '[' is discarded rather than handed to Gson as-is. + */ + private T parseJson(String rawResult, Type returnType, String[] command) { + String json = stripLeadingNonJson(rawResult); + try { + return gson.fromJson(json, returnType); + } catch (JsonSyntaxException e) { + throw new CommandException("command '" + String.join(" ", Arrays.asList(command)) + + "' did not return the expected JSON output: " + rawResult, e); + } + } + + private String stripLeadingNonJson(String rawResult) { + if (rawResult == null) { + return null; + } + int objectStart = rawResult.indexOf('{'); + int arrayStart = rawResult.indexOf('['); + int jsonStart = objectStart < 0 ? arrayStart : arrayStart < 0 ? objectStart : Math.min(objectStart, arrayStart); + return jsonStart <= 0 ? rawResult : rawResult.substring(jsonStart); } } diff --git a/src/test/java/io/oneko/helmapi/api/HelmTest.java b/src/test/java/io/oneko/helmapi/api/HelmTest.java index a0732620..91b91a66 100644 --- a/src/test/java/io/oneko/helmapi/api/HelmTest.java +++ b/src/test/java/io/oneko/helmapi/api/HelmTest.java @@ -32,6 +32,10 @@ void setup() throws IOException { tce.addDefinedCommand(helm_list, "helm list -o=json --time-format=2006-01-02T15:04:05Z07:00"); tce.addDefinedCommand("v3.4.2+g23dd3af", "helm version --short"); tce.addDefinedCommand(helm_install, "helm install ontest o-neko --namespace=deletethispls -f=" + tempFile.getAbsolutePath() + " --dry-run=false --wait=false -o=json"); + tce.addDefinedCommand(helm_install, "helm install ontest oci://localhost:5001/charts/foo --version=1.0.0 --namespace=deletethispls -f=" + tempFile.getAbsolutePath() + " --dry-run=false --wait=false -o=json --plain-http=true"); + tce.addDefinedCommand("login success", "helm registry login localhost:5001 --username=user --password=pass --plain-http=true"); + tce.addDefinedCommand("login success", "helm registry login registry.example.com/path --username=user --password=pass --plain-http=false"); + tce.addDefinedCommand("logout success", "helm registry logout localhost:5001"); uut = new Helm(tce); } @@ -46,6 +50,29 @@ void install() throws IOException { assertThat(result.getChart().getMetadata().getName()).isEqualTo("o-neko"); } + @Test + void installOciChartWithExplicitPlainHttp() throws IOException { + Files.writeString(tempFile.toPath(), ONEKO_VALUES); + var result = uut.install("ontest", "oci://localhost:5001/charts/foo", "1.0.0", Values.fromFile(tempFile), "deletethispls", false, false, true); + assertThat(result).isNotNull(); + assertThat(result.getName()).isEqualTo("ontest"); + } + + @Test + void loginToOciRegistryWithExplicitPlainHttp() { + uut.loginToRegistry("oci://http://localhost:5001", "user", "pass"); + } + + @Test + void loginToOciRegistryWithExplicitHttps() { + uut.loginToRegistry("oci://https://registry.example.com/path", "user", "pass"); + } + + @Test + void logoutFromOciRegistry() { + uut.logoutFromRegistry("oci://http://localhost:5001"); + } + @Test void version() { assertThat(uut.version()).isEqualTo("v3.4.2+g23dd3af"); diff --git a/src/test/java/io/oneko/helmapi/api/OciRegistryUrlTest.java b/src/test/java/io/oneko/helmapi/api/OciRegistryUrlTest.java new file mode 100644 index 00000000..15660397 --- /dev/null +++ b/src/test/java/io/oneko/helmapi/api/OciRegistryUrlTest.java @@ -0,0 +1,45 @@ +package io.oneko.helmapi.api; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class OciRegistryUrlTest { + + @Test + void explicitPlainHttpSchemeIsRecognizedWithoutProbing() { + var registryUrl = OciRegistryUrl.resolve("oci://http://localhost:5001/some/path"); + + assertThat(registryUrl.plainHttp()).isTrue(); + assertThat(registryUrl.host()).isEqualTo("localhost:5001"); + assertThat(registryUrl.repositoryPathPrefix()).isEqualTo("some/path"); + assertThat(registryUrl.getHostAndPath()).isEqualTo("localhost:5001/some/path"); + } + + @Test + void explicitHttpsSchemeIsRecognizedWithoutProbing() { + var registryUrl = OciRegistryUrl.resolve("oci://https://registry.example.com/some/path"); + + assertThat(registryUrl.plainHttp()).isFalse(); + assertThat(registryUrl.host()).isEqualTo("registry.example.com"); + assertThat(registryUrl.getHostAndPath()).isEqualTo("registry.example.com/some/path"); + } + + @Test + void noSchemeAndNoPathFallsBackToProbingAndYieldsPlainHttpWhenUnreachable() { + var registryUrl = OciRegistryUrl.resolve("oci://127.0.0.1:1"); + + assertThat(registryUrl.plainHttp()).isTrue(); + assertThat(registryUrl.host()).isEqualTo("127.0.0.1:1"); + assertThat(registryUrl.repositoryPathPrefix()).isEmpty(); + assertThat(registryUrl.getHostAndPath()).isEqualTo("127.0.0.1:1"); + } + + @Test + void hostAndPathStripsSchemesWithoutProbing() { + assertThat(OciRegistryUrl.hostAndPath("oci://http://localhost:5001/foo")).isEqualTo("localhost:5001/foo"); + assertThat(OciRegistryUrl.hostAndPath("oci://https://registry.example.com/foo")).isEqualTo("registry.example.com/foo"); + assertThat(OciRegistryUrl.hostAndPath("oci://registry.example.com/foo")).isEqualTo("registry.example.com/foo"); + } + +} diff --git a/src/test/java/io/oneko/helmapi/process/DelegatingCommandExecutorTest.java b/src/test/java/io/oneko/helmapi/process/DelegatingCommandExecutorTest.java new file mode 100644 index 00000000..a4fff07c --- /dev/null +++ b/src/test/java/io/oneko/helmapi/process/DelegatingCommandExecutorTest.java @@ -0,0 +1,43 @@ +package io.oneko.helmapi.process; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class DelegatingCommandExecutorTest { + + @Test + void leadingOciPullProgressLinesBeforeTheJsonAreIgnored() { + ICommandExecutor delegate = command -> "Pulled: localhost:5001/helm-charts/demo:0.1.0\n" + + "Digest: sha256:bdffdbc74f05ddfd83f08afaa12811455f893a5be4e6612bd32baff2d7b049bb\n" + + "{\"name\":\"ontest\"}"; + var uut = new DelegatingCommandExecutor(delegate); + + var result = uut.executeWithJsonOutput(Map.class, "helm", "install"); + + assertThat(result).containsEntry("name", "ontest"); + } + + @Test + void jsonArrayOutputIsStillParsedCorrectly() { + ICommandExecutor delegate = command -> "[{\"name\":\"a\"},{\"name\":\"b\"}]"; + var uut = new DelegatingCommandExecutor(delegate); + + var result = uut.executeWithJsonOutput(java.util.List.class, "helm", "list"); + + assertThat(result).hasSize(2); + } + + @Test + void nonJsonOutputStillFailsWithTheRawOutputAttached() { + ICommandExecutor delegate = command -> "Error: something went wrong, but somehow exited 0"; + var uut = new DelegatingCommandExecutor(delegate); + + assertThatThrownBy(() -> uut.executeWithJsonOutput(Map.class, "helm", "install")) + .isInstanceOf(CommandException.class) + .hasMessageContaining("Error: something went wrong, but somehow exited 0"); + } + +}