Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,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";
Expand Down Expand Up @@ -89,9 +90,12 @@ export class TemplateEditorComponent implements OnInit {

@Select(ThemingState.isDarkMode) isDarkTheme$: Observable<boolean>;

public registryNameByRegistryId: {[name: string]: string} = {};
public registryByRegistryId: {[id: string]: HelmRegistry} = {};
public chartRegistries: Observable<Array<HelmRegistry>>;
public chartsByRegistry: {[registry: string]: HelmCharts} = {};
public ociChartPathControl = new UntypedFormControl('');
public ociChartLookupStatus: 'found' | 'not-found' = 'not-found';
public chartNameFocused: boolean;

public editorOptions: any = {
theme: 'vs-light',
Expand All @@ -114,6 +118,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<string> {
if (!this.currentHelmCharts || !this.currentHelmCharts.charts) {
return [];
Expand Down Expand Up @@ -141,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 {
Expand Down Expand Up @@ -168,16 +212,17 @@ 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];
}

public async onConfigUpload($event: FileList | File[]) {
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/app/deployable/template-editor/template-editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,27 @@ <h4>{{label}}</h4>
<mat-option *ngFor="let registry of (chartRegistries | async)" [value]="registry.id">{{ registry.name }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-form-field *ngIf="!currentRegistryIsOci">
<mat-label>{{'components.templateEditor.chartName' | translate}}</mat-label>
<mat-select [disabled]="!currentTemplateModel.effectiveTemplate.helmRegistryId" required [(ngModel)]="currentTemplateModel.effectiveTemplate.chartName" (ngModelChange)="helmChartSettingsChanged()">
<mat-option *ngFor="let chartName of currentHelmChartNames" [value]="chartName">{{ removeRegistryPart(chartName) }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="oci-chart-path-field" *ngIf="currentRegistryIsOci" (focusin)="chartNameFocused = true" (focusout)="chartNameFocused = false">
<mat-label>{{'components.templateEditor.ociChartPath' | translate}}</mat-label>
<input matInput [formControl]="ociChartPathControl" [placeholder]="(currentRegistry?.url || '') + '/...'">
<div matSuffix class="oci-chart-status" fxLayout="row" fxLayoutAlign="center center">
<mat-icon
*ngIf="ociChartLookupStatus === 'found' && chartNameFocused" svgIcon="mdi:check-circle" class="oci-chart-found-icon"
[matTooltip]="'components.templateEditor.ociChartFound' | translate">
</mat-icon>
<mat-icon
*ngIf="ociChartLookupStatus === 'not-found' && chartNameFocused" svgIcon="mdi:close-circle" class="oci-chart-not-found-icon"
[matTooltip]="'components.templateEditor.ociChartNotFound' | translate">
</mat-icon>
</div>
<mat-hint *ngIf="currentTemplateModel.effectiveTemplate.chartName">{{currentTemplateModel.effectiveTemplate.chartName}}</mat-hint>
</mat-form-field>
<mat-form-field>
<mat-label>{{'components.templateEditor.chartVersionPlaceholder' | translate}}</mat-label>
<mat-select [disabled]="!currentTemplateModel.effectiveTemplate.chartName" [(ngModel)]="currentTemplateModel.effectiveTemplate.chartVersion" (ngModelChange)="helmChartSettingsChanged()">
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/app/deployable/template-editor/template-editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,23 @@ <h1 mat-dialog-title>{{(helmRegistry.isNew() ? 'components.helmRegistry.editDial
<div mat-dialog-content="">
<div>
<form fxLayout="column" #helmRegistryForm="ngForm" fxLayoutGap="1em">
<div fxLayout="row" fxLayoutGap="1em">
<mat-form-field>
<input type="text" matInput [(ngModel)]="helmRegistry.name" autocomplete="off" required name="name" id="name"
[placeholder]="'general.name' | translate" #name="ngModel" [disabled]="isReadonly()" (ngModelChange)="nameUpdated()">
<mat-error *ngIf="name.invalid && name.errors.required">{{'components.forms.errors.fieldIsRequired' | translate}}</mat-error>
</mat-form-field>
<mat-form-field>
<input type="text" matInput [(ngModel)]="helmRegistry.url" autocomplete="off" required name="url" id="url"
[placeholder]="'components.helmRegistry.registryUrl' | translate" #url="ngModel" [disabled]="isReadonly()">
<mat-error *ngIf="url.invalid && url.errors.required">{{'components.forms.errors.fieldIsRequired' | translate}}</mat-error>
</mat-form-field>
</div>
<mat-form-field>
<input type="text" matInput [(ngModel)]="helmRegistry.name" autocomplete="off" required name="name" id="name"
[placeholder]="'general.name' | translate" #name="ngModel" [disabled]="isReadonly()" (ngModelChange)="nameUpdated()">
<mat-error *ngIf="name.invalid && name.errors.required">{{'components.forms.errors.fieldIsRequired' | translate}}</mat-error>
</mat-form-field>
<mat-radio-group [attr.aria-label]="'components.helmRegistry.editDialog.registryType' | translate"
name="registryType" [(ngModel)]="registryType" (ngModelChange)="urlUpdated()"
[disabled]="isReadonly()" fxLayout="row" fxLayoutGap="1em">
<mat-radio-button value="classic">{{'components.helmRegistry.editDialog.classicRegistry' | translate}}</mat-radio-button>
<mat-radio-button value="oci">{{'components.helmRegistry.editDialog.ociRegistry' | translate}}</mat-radio-button>
</mat-radio-group>
<mat-form-field floatLabel="never">
<span *ngIf="registryType === 'oci'" matPrefix>{{OCI_PREFIX}}</span>
<input type="text" matInput [(ngModel)]="registryUrl" (ngModelChange)="urlUpdated()" autocomplete="off" required name="url" id="url"
[placeholder]="'components.helmRegistry.registryUrl' | translate" #url="ngModel" [disabled]="isReadonly()">
<mat-error *ngIf="url.invalid && url.errors.required">{{'components.forms.errors.fieldIsRequired' | translate}}</mat-error>
</mat-form-field>
<div fxLayout="row" fxLayoutGap="1em">
<mat-form-field>
<input type="text" #username="ngModel" matInput [(ngModel)]="helmRegistry.username" name="username" id="username" [placeholder]="'general.username' | translate" [disabled]="isReadonly()">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HelmRegistryEditDialogComponent>,
@Inject(MAT_DIALOG_DATA) private data: { registry: HelmRegistry, readOnly: boolean },
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
}
3 changes: 3 additions & 0 deletions frontend/src/app/registries/helm/helm-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@ export interface HelmRegistryDTO extends Registry {
id: string
url: string
username: string
ociRegistry: boolean
}

export class HelmRegistry implements HelmRegistryDTO {
id: string
name: string
url: string
username: string
ociRegistry: boolean

public static from(from: HelmRegistryDTO): HelmRegistry {
let registry = new HelmRegistry();
registry.id = from.id;
registry.name = from.name;
registry.url = from.url;
registry.username = from.username;
registry.ociRegistry = from.ociRegistry;
return registry;
}

Expand Down
12 changes: 10 additions & 2 deletions frontend/src/app/rest/helm-rest.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -24,7 +24,7 @@ export class HelmRestService {
public persistHelmRegistry(helmRegistry: HelmRegistry, password: string): Observable<HelmRegistry> {
if (helmRegistry.isNew()) {
//create new helmRegistry call
return this.http.post<HelmRegistryDTO>(this.root_path, {...helmRegistry, password}).pipe(map(helmRegistryDTO => HelmRegistry.from(helmRegistryDTO)));;
return this.http.post<HelmRegistryDTO>(this.root_path, {...helmRegistry, password}).pipe(map(helmRegistryDTO => HelmRegistry.from(helmRegistryDTO)));
}

//update the existing project
Expand All @@ -46,4 +46,12 @@ export class HelmRestService {
public getHelmChartsByRegistry(helmRegistry: HelmRegistry): Observable<HelmCharts> {
return this.http.get<HelmCharts>(`/${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<Array<HelmChartVersion>> {
return this.http.get<Array<HelmChartVersion>>(`/${this.root_path}/${helmRegistry.id}/charts/lookup`, {params: {chartPath}});
}
}
8 changes: 7 additions & 1 deletion frontend/src/assets/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@
"overwritesInheritedConfiguration": "Das überschreibt eine geerbte Konfiguration.",
"chartRegistry": "Chart Registry",
"chartName": "Chartname",
"ociChartPath": "Chart-Pfad (relativ zur Registry-URL)",
"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",
"noTemplatesAvailable": "Kein Template vorhanden.",
Expand Down Expand Up @@ -140,7 +143,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",
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@
"overwritesInheritedConfiguration": "This overwrites an inherited configuration.",
"chartRegistry": "Chart registry",
"chartName": "Chart name",
"ociChartPath": "Chart path (relative to registry URL)",
"ociChartFound": "Helm chart found at this path",
"ociChartNotFound": "No helm chart found at this path",
"chartVersion": "Chart version",
"chartVersionPlaceholder": "Leave blank for latest version",
"noTemplatesAvailable": "No configuration templates.",
Expand Down Expand Up @@ -140,7 +143,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",
Expand Down
37 changes: 33 additions & 4 deletions src/main/java/io/oneko/helm/HelmCharts.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,38 @@

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<UUID, HelmChartsDTO> 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()
.build(registryId -> helmRegistryRepository.getById(registryId)
.flatMap(helmRegistry -> {
try {
List<Chart> charts = helmCommands.getCharts(helmRegistry);
log.debug("found helm charts ({}, {})", kv("chart_count", charts.size()), helmRegistryKv(helmRegistry));
if (!helmRegistry.isOCIRegistry()) {
List<Chart> charts = helmCommands.getCharts(helmRegistry);
log.debug("found helm charts ({}, {})", kv("chart_count", charts.size()), helmRegistryKv(helmRegistry));

return toHelmChartDTO(helmRegistry, charts);
return toHelmChartDTO(helmRegistry, charts);
} else {
List<Chart> 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();
}
Expand All @@ -62,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<HelmChartsDTO.HelmChartVersionDTO> 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<HelmChartsDTO> toHelmChartDTO(HelmRegistry helmRegistry, List<Chart> charts) {
if (charts.isEmpty()) {
return Optional.empty();
Expand Down
Loading