Skip to content
Merged
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
64 changes: 64 additions & 0 deletions app-catalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,70 @@ After completing these steps, you'll see the App Catalog link in the sidebar.

![Screenshot of the App Catalog link in the sidebar](https://github.com/user-attachments/assets/5ee65579-abfc-4820-bf83-bcc4e2bea0f5 "Screenshot of the App Catalog link in the sidebar")

## App Catalog supported labels and annotations
The App-Catalog plugin in Headlamp discovers and lists application catalogs by scanning Kubernetes Service resources.
To be recognized as a catalog source, the Service must include specific labels and annotations that describe how the plugin should interact with it.

Catalogs can be either:
- External sources
- Internal in-cluster helm repositories or custom chart services

| Label | Description |
|------------------------------------|--------------------------------------------------------------------------------|
| catalog.headlamp.dev/is-catalog | Indicates that this Service should be treated as an application catalog. |


| Annotaion | Description |
|----------------------------------|---------------------------------------------------------------------------------------------------------------------------|
| catalog.headlamp.dev/name | Internal identifier for the catalog. It'll be used as displayName if `displayName` is empty. |
| catalog.headlamp.dev/protocol | Specifies the catalog API protocol. Supported values are helm (for in-cluster service, artifacthub (for external service) |
| catalog.headlamp.dev/displayName | (optional) User-friendly display name shown in UI. |
| catalog.headlamp.dev/uri | URL or endpoint used to fetch catalog data. For external catalogs, this must be a valid HTTP(S) URL. |

### Sample external-service to access artifacthub.io
```yaml
apiVersion: v1
kind: Service
metadata:
name: artifacthub-catalog
namespace: artifacthub
labels:
catalog.headlamp.dev/is-catalog: ""
annotations:
catalog.headlamp.dev/name: artifacthub-catalog
catalog.headlamp.dev/protocol: artifacthub
catalog.headlamp.dev/uri: https://artifacthub.io
spec:
type: ExternalName
externalName: artifacthub.io
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP
```
### Sample in-cluster service to access catalog running in-cluster
```yaml
apiVersion: v1
kind: Service
metadata:
name: demo-catalog
namespace: test-catalog
labels:
catalog.headlamp.dev/is-catalog: ""
annotations:
catalog.headlamp.dev/name: demo-catalog
catalog.headlamp.dev/protocol: helm
catalog.headlamp.dev/displayName: My demo catalog
spec:
type: NodePort
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP
```


## Contributing

Expand Down
51 changes: 51 additions & 0 deletions app-catalog/src/api/catalogConfig.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Configuration object for chart settings.
*
* @property chartURLPrefix - The prefix for chart URLs.
* @property chartProfile - The profile for charts.
* @property chartValuesPrefix - The prefix for chart values.
* @property catalogNamespace - The namespace for the catalog.
* @property catalogName - The name of the catalog.
*/
export type ChartConfig = {
chartURLPrefix: string;
chartProfile: string;
chartValuesPrefix: string;
catalogNamespace: string;
catalogName: string;
};

const catalogConfig: ChartConfig = {
chartURLPrefix: '',
chartProfile: '',
chartValuesPrefix: '',
catalogNamespace: '',
catalogName: '',
};

/**
* Sets the prefix for chart values in the catalog configuration.
* @param valuesPrefix - The prefix to be used for chart values.
* @param valuesPrefix - The new prefix for chart values.
*/
export function setChartValuesPrefix(valuesPrefix: string) {
catalogConfig.chartValuesPrefix = valuesPrefix;
}

/**
* Updates the catalog configuration with the provided partial configuration.
*
* @param update - A partial ChartConfig object containing the properties to be updated.
*/
export function setCatalogConfig(update: Partial<ChartConfig>) {
Object.assign(catalogConfig, update);
}

/**
* Retrieves the current catalog configuration.
*
* @returns {Readonly<ChartConfig>} The current catalog configuration.
*/
export function getCatalogConfig(): Readonly<ChartConfig> {
return catalogConfig;
}
74 changes: 74 additions & 0 deletions app-catalog/src/api/catalogs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { QueryParameters, request } from '@kinvolk/headlamp-plugin/lib/ApiProxy';
import { ChartsList } from '../components/charts/List';
import { COMMUNITY_REPO, VANILLA_HELM_REPO } from '../constants/catalog';
import { setCatalogConfig } from './catalogConfig';

const SERVICE_ENDPOINT = '/api/v1/services';
const LABEL_CATALOG = 'catalog.headlamp.dev/is-catalog';

/**
* Resets the global variables used for chart configuration.
* Reset the prefix and profile, to switch between different catalogs
* @param metadataName - The metadata name of the catalog.
* @param namespace - The namespace of the catalog.
* @param prefix - The prefix for chart URLs.
* @param profile - The profile for charts.
* @param valuesPrefix - The prefix for chart values.
*/
export function ResetGlobalVars(
metadataName: string,
namespace: string,
prefix: string,
profile: string,
valuesPrefix: string
) {
setCatalogConfig({
chartURLPrefix: prefix,
chartProfile: profile,
chartValuesPrefix: valuesPrefix,
catalogNamespace: namespace,
catalogName: metadataName,
});
}

/**
* Resets the global variables and renders the ChartsList component for a Helm chart.
*
* @param metadataName - The metadata name of the catalog.
* @param namespace - The namespace of the catalog.
* @param chartUrl - The URL of the Helm chart repository.
*
* @returns The ChartsList component.
*/
export function HelmChartList(metadataName: string, namespace: string, chartUrl: string) {
ResetGlobalVars(metadataName, namespace, chartUrl, VANILLA_HELM_REPO, `values`);
return <ChartsList />;
}

/**
* Resets the global variables and renders the ChartsList component for a community chart(artifacthub).
*
* @param metadataName - The metadata name of the catalog.
* @param namespace - The namespace of the catalog.
* @param chartUrl - The URL of the community chart repository.
*
* @returns The ChartsList component for the community chart.
*/
export function CommunityChartList(metadataName: string, namespace: string, chartUrl: string) {
ResetGlobalVars(metadataName, namespace, chartUrl, COMMUNITY_REPO, '');
return <ChartsList />;
}

/**
* Fetches the list of catalogs in the cluster.
* It uses a query parameter to fetch services with the given label.
*
* @returns A promise resolving to the response from the request.
*/
export function fetchCatalogs() {
// Use query parameter to fetch the services with label catalog.headlamp.dev/is-catalog
const queryParam: QueryParameters = {
labelSelector: LABEL_CATALOG + '=',
};
return request(SERVICE_ENDPOINT, {}, true, true, queryParam).then(response => response);
}
146 changes: 140 additions & 6 deletions app-catalog/src/api/charts.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,85 @@
import { PAGE_OFFSET_COUNT_FOR_CHARTS } from '../components/charts/List';
import { request } from '@kinvolk/headlamp-plugin/lib/ApiProxy';
import {
COMMUNITY_REPO,
CUSTOM_CHART_VALUES_PREFIX,
PAGE_OFFSET_COUNT_FOR_CHARTS,
VANILLA_HELM_REPO,
} from '../constants/catalog';
import { yamlToJSON } from '../helpers';
import { isElectron } from '../index';
import { getCatalogConfig, setChartValuesPrefix } from './catalogConfig';

// Headlamp plugin's backend service proxy endpoint.
// It was implemented by Headlamp's backed to proxies in-cluster requests to handle authentication
const SERVICE_PROXY = '/serviceproxy';

/**
* Encodes a URL as a query parameter for another URL.
*
* @param url - The URL to be encoded as a query parameter.
* @returns The encoded URL as a query parameter.
*/
const getURLSearchParams = url => {
return new URLSearchParams({ request: url }).toString();
};

/**
* Fetches charts from the Artifact repository based on the provided search criteria.
*
* @param search - The search query to filter charts.
* @param verified - Whether to fetch charts from verified publishers.
* @param category - The category to filter charts by.
* @param page - The page number to fetch.
* @param [limit=PAGE_OFFSET_COUNT_FOR_CHARTS] - The number of charts to fetch per page.
* @returns An object containing the fetched charts and the total count.
*/
export async function fetchChartsFromArtifact(
search: string = '',
verified: boolean,
category: { title: string; value: number },
page: number,
limit: number = PAGE_OFFSET_COUNT_FOR_CHARTS
) {
if (!isElectron()) {
const chartCfg = getCatalogConfig();
if (chartCfg.chartProfile === VANILLA_HELM_REPO) {
// When chartProfile is VANILLA_HELM_REPOSITORY, the code expects /charts/index.yaml
// to contain the metadata of the available charts
const url =
`${SERVICE_PROXY}/${chartCfg.catalogNamespace}/${chartCfg.catalogName}?` +
getURLSearchParams(`charts/index.yaml`);

// Ensure that the UI renders index.yaml in yaml and json format. Please note that, helm repo index generates index.yaml
// in yaml as the default format, although latest versions support generating the file in json format.
// The API yamlToJSON works for the response in yaml as well as json format.
const dataResponse = await request(url, { isJSON: false }, true, true, {});
const yamlResponse = (await dataResponse?.text()) ?? '';
const jsonResponse = yamlToJSON(yamlResponse) as Record<string, unknown>;
const total = Object.keys(jsonResponse.entries ?? {}).length;
return { data: jsonResponse, total };
} else if (chartCfg.chartProfile === COMMUNITY_REPO) {
let requestParam = '';
if (!category || category.value === 0) {
requestParam = `api/v1/packages/search?kind=0&ts_query_web=${search}&sort=relevance&facets=true&limit=${limit}&offset=${
(page - 1) * limit
}`;
} else {
requestParam = `api/v1/packages/search?kind=0&ts_query_web=${search}&category=${
category.value
}&sort=relevance&facets=true&limit=${limit}&offset=${(page - 1) * limit}`;
}

const url =
`${SERVICE_PROXY}/${chartCfg.catalogNamespace}/${chartCfg.catalogName}?` +
getURLSearchParams(requestParam);
const dataResponse = await request(url, {}, true, true, {}).then(response => response);
const jsonResponse = await dataResponse.json();
const total = dataResponse.headers?.get('pagination-total-count') ?? 0;
return { data: jsonResponse, total };
}
}

// App-catalog desktop version
// note: we are currently defaulting to search by verified and official as default
const url = new URL('https://artifacthub.io/api/v1/packages/search');
url.searchParams.set('offset', ((page - 1) * limit).toString());
Expand All @@ -22,25 +95,86 @@ export async function fetchChartsFromArtifact(
url.searchParams.set('verified_publisher', verified.toString());

const response = await fetch(url.toString());
const total = response.headers.get('pagination-total-count');

const dataResponse = await response.json();

return { dataResponse, total };
const total = response.headers?.get('pagination-total-count') ?? 0;
const jsonResponse = await response.json();
return { data: jsonResponse, total };
}

/**
* Fetches the details of a chart from the Artifact repository.
*
* @param chartName - The name of the chart to fetch details for.
* @param repoName - The name of the repository where the chart is located.
* @returns A promise that resolves to the chart details.
*/
export function fetchChartDetailFromArtifact(chartName: string, repoName: string) {
const chartCfg = getCatalogConfig();
// Use /serviceproxy to fetch the resource, by specifying the access token
if (!isElectron() && chartCfg.chartProfile === COMMUNITY_REPO) {
const url =
`${SERVICE_PROXY}/${chartCfg.catalogNamespace}/${chartCfg.catalogName}?` +
getURLSearchParams(`api/v1/packages/helm/${repoName}/${chartName}`);
return request(url, {}, true, true, {}).then(response => response);
}

// Use /externalproxy for App-catalog desktop version

return fetch(`http://localhost:4466/externalproxy`, {
headers: {
'Forward-To': `https://artifacthub.io/api/v1/packages/helm/${repoName}/${chartName}`,
},
}).then(response => response.json());
}

/**
* Fetches the chart values for a specific package and version.
*
* @param packageID - The ID of the package to fetch chart values for.
* @param packageVersion - The version of the package to fetch chart values for.
* @returns A promise that resolves to the chart values as a string.
*/
export function fetchChartValues(packageID: string, packageVersion: string) {
const chartCfg = getCatalogConfig();
if (!isElectron()) {
let requestParam = '';
if (chartCfg.chartProfile === VANILLA_HELM_REPO) {
// When the token CUSTOM_CHART_VALUES_PREFIX is replaced during the deployment, expect the values.yaml for the specified
// package and version accessible on ${CUSTOM_CHART_VALUES_PREFIX}/${packageID}/${packageVersion}/values.yaml
if (CUSTOM_CHART_VALUES_PREFIX !== 'CUSTOM_CHART_VALUES_PREFIX') {
setChartValuesPrefix(`${CUSTOM_CHART_VALUES_PREFIX}`);
}
// The code expects /${packageID}/${packageVersion}/values.yaml to return values.yaml for the component
// denoted by packageID and a given packageVersion. Please note that, chart.name is used for packageID in this case.
requestParam = `${chartCfg.chartValuesPrefix}/${packageID}/${packageVersion}/values.yaml`;
} else if (chartCfg.chartProfile === COMMUNITY_REPO) {
requestParam = `api/v1/packages/${packageID}/${packageVersion}/values`;
}
const url =
`${SERVICE_PROXY}/${chartCfg.catalogNamespace}/${chartCfg.catalogName}?` +
getURLSearchParams(requestParam);

// Use /serviceproxy to fetch the resource, by specifying the access token
return request(url, { isJSON: false }, true, true, {}).then(response => response.text());
}

// Use /externalproxy for App-catalog desktop version
return fetch(`http://localhost:4466/externalproxy`, {
headers: {
'Forward-To': `https://artifacthub.io/api/v1/packages/${packageID}/${packageVersion}/values`,
},
}).then(response => response.text());
}

/**
* Fetches the chart icon from the Artifact repository based on the provided icon name.
*
* @param iconName - The name of the icon to fetch.
* @returns A promise that resolves to the chart icon response.
*/
export async function fetchChartIcon(iconName: string) {
const chartCfg = getCatalogConfig();
const url =
`${SERVICE_PROXY}/${chartCfg.catalogNamespace}/${chartCfg.catalogName}?` +
getURLSearchParams(`${iconName}`);
return request(url, { isJSON: false }, true, true, {}).then(response => response);
}
Loading
Loading