Skip to content
Open
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
2 changes: 1 addition & 1 deletion app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ proxy:
'/harnessfme':
target: 'https://api.split.io/'
headers:
'Authorization': 'Bearer ${FME_API_KEY}'
'x-api-key': ${HARNESS_PROD_API_KEY} #use x-api-key for migrated environments, use `Authorization: Bearer <key>` for non-migrated environments

# Reference documentation http://backstage.io/docs/features/techdocs/configuration
# Note: After experimenting with basic setup, use CI/CD to generate docs
Expand Down
6 changes: 4 additions & 2 deletions examples/entities.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ metadata:
annotations:
# mandatory annotation
# harness.io/project-url: https://app.harness.io/ng/account/HARNESS_ACCOUNT_ID/module/orgs/HARNESS_ORG_ID/projects/HARNESS_PROJECT_NAME/feature-flags
# harnessfme/projectId: FME_PROJECT_ID
# harnessfme/accountId: FME_ACCOUNT_ID
# harnessfme/mywork: https://app.harness.io/ng/account/HARNESS_ACCOUNT_ID/module/fme/orgs/HARNESS_ORG_ID/projects/HARNESS_PROJECT_ID/org/fmeAcountId/ws/fmeProjectId/mywork
harnessfme/isMigrated: 'false'
harnessfme/accountId: 'ACCOUNT_ID'
harnessfme/projectId: 'PROJECT_ID'

spec:
type: service
Expand Down
86 changes: 74 additions & 12 deletions plugins/harness-fme-feature-flags/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Website: [https://harness.io/](https://harness.io/)

Welcome to the Harness FME Feature Flags plugin for Backstage!

This plugin supports both **migrated** and **non-migrated** Split.io environments:
- **Migrated environments**: Feature flags that have been migrated to Harness FME (uses Harness APIs)
- **Non-migrated environments**: Feature flags still running on Split.io (uses Split.io APIs)

The plugin automatically detects which environment type you're using based on your entity annotations and routes API calls accordingly.

## Screenshots

<img src="./src/assets/FeatureList.png" />
Expand All @@ -22,23 +28,50 @@ If you are looking to get started with Backstage, check out [backstage.io/docs](

For testing purposes, you can also clone this repository to try out the plugin. It contains an example Backstage app setup which is pre-installed with Harness plugins. However, you must create a new Backstage app if you are looking to get started with Backstage.

2. Configure proxy for harness in your `app-config.yaml` under the `proxy` config. Add your Harness FME Admin API Key for `Authorization: Bearer`. See the [Harness FME docs](https://help.split.io/hc/en-us/articles/360019916211-API-keys) for generating an API Key.
2. Configure proxy settings in your `app-config.yaml` under the `proxy` config.

### For Migrated Environments (Harness FME)
Ensure you have a service account for `x-api-key`. See the [Harness API Docs](https://developer.harness.io/docs/platform/automation/api/api-quickstart/) for generating an API Key.

```yaml
# In app-config.yaml

proxy:
# ... existing proxy settings
'/harness/prod':
target: 'https://app.harness.io/'
headers:
'x-api-key': '<Harness API Token>'
# ...

# You can also configure the base URLs in app-config.yaml
harness:
baseUrl: 'https://app.harness.io/'
```

### For Non-Migrated Environments (Split.io)
Configure Split.io proxy with your Split.io API token:

```yaml
# In app-config.yaml

proxy:
# ... existing proxy settings
'/harnessfme':
'/harnessfme/internal':
target: 'https://api.split.io/'
headers:
'Authorization': 'Bearer <API KEY>'
'Authorization': 'Bearer <Split.io API Token>'
# ...

harnessfme:
baseUrl: 'https://api.split.io/'
```

Notes:
**Notes:**

- Plugin uses token configured here to make Harness FME API calls. Make sure this token has the necessary permissions
- For **migrated environments**: Plugin uses the Harness API token to make Harness FME API calls
- For **non-migrated environments**: Plugin uses the Split.io API token to make Split.io API calls
- Make sure tokens have the necessary permissions for feature flag operations


3. Inside your Backstage's `EntityPage.tsx`, add the new `FMEfeatureFlagList` component to render `<EntityHarnessFMEFeatureFlagContent />` whenever the service is using Harness Feature Flags. Something like this -
Expand Down Expand Up @@ -92,29 +125,58 @@ const serviceEntityPage = (

```

4. Add required Harness FME specific annotations to your software component's respective `catalog-info.yaml` file.
You will need your accountId (formerly Org ID) and projectId (formerly Workspace ID)
4. Add required annotations to your software component's respective `catalog-info.yaml` file.

You can get these from the URL when you are logged in to the FME console.
The plugin supports two configuration modes based on your environment:

https://app.split.io/org/<ACCOUNT ID>/ws/<PROJECT ID>>/mywork
### For Migrated Environments (Harness FME)

You will need your `My Work` URL from the Harness FME console. Log into your Harness FME console, navigate to the `My Work` section, and copy the URL from the browser.

Example URL:
```
https://app.harness.io/ng/account/HARNESS_ACCOUNT_ID/module/fme/orgs/HARNESS_ORG_ID/projects/HARNESS_PROJECT_ID/org/fmeAcountId/ws/fmeProjectId/mywork
```

```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
# ...
annotations:
# mandatory annotation
harnessfme/projectId: <project id>
harnessfme/accountId: <account id>
# Required for migrated environments
harnessfme/mywork: https://app.harness.io/ng/account/HARNESS_ACCOUNT_ID/module/fme/orgs/HARNESS_ORG_ID/projects/HARNESS_PROJECT_ID/org/fmeAcountId/ws/fmeProjectId/mywork
harnessfme/isMigrated: "true" # This tells the plugin to use Harness APIs
spec:
type: service
# ...
```

### For Non-Migrated Environments (Split.io)

For environments still running on Split.io, you need to provide the account and project IDs directly:

You can get these from the URL when you are logged in to the Split.io console on the mywork page.

https://app.split.io/org/ACCOUNT_ID/ws/PROJECT_ID/mywork

```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
# ...
annotations:
# Required for non-migrated environments
harnessfme/accountId: ACCOUNT_ID # Your Split.io account ID
harnessfme/projectId: PROJECT_ID # Your Split.io workspace ID
harnessfme/isMigrated: "false" # This tells the plugin to use Split.io APIs
spec:
type: service
# ...
```

**Important:** The `harnessfme/isMigrated` annotation determines which API endpoints and authentication methods the plugin uses. Set it to `"true"` for migrated environments or `"false"` for Split.io environments.



## Features
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,17 @@ function FMEFeatureList() {
const discoveryApi = useApi(discoveryApiRef);
discoveryApi.getBaseUrl('proxy').then(url => setResolvedBackendBaseUrl(url));
const config = useApi(configApiRef);
const baseUrl =
config.getOptionalString('harnessfme.baseUrl') ?? 'https://app.split.io/';
const { workspaceId, orgId } = useProjectSlugFromEntity();
const harnessBaseUrl =
config.getOptionalString('harness.baseUrl') ?? 'https://app.harness.io/';
const fmeBaseUrl = 'https://app.split.io/';
const {
workspaceId,
orgId,
harnessAccountId,
harnessOrgId,
harnessProjectId,
isMigrated,
} = useProjectSlugFromEntity();

// Memoize the refresh callback
const refresh = useCallback(() => {
Expand Down Expand Up @@ -114,16 +122,43 @@ function FMEFeatureList() {
const featureStatus = featureStatusMap[row.name as string] || {
id: '',
};
const link = `${baseUrl}org/${orgId}/ws/${workspaceId}/splits/${featureStatus.id}/env/${envId.id}/definition`;
const link =
isMigrated === 'true'
? `${harnessBaseUrl}ng/account/${harnessAccountId}/module/fme/orgs/${harnessOrgId}/projects/${harnessProjectId}/org/${orgId}/ws/${workspaceId}/splits/${featureStatus.id}/env/${envId.id}/definition`
: `${fmeBaseUrl}org/${orgId}/ws/${workspaceId}/splits/${featureStatus.id}/env/${envId.id}/definition`;
return (
<Link href={link} target="_blank">
<b>{row.name}</b>
</Link>
);
},
customFilterAndSearch: (term, row: Partial<TableData>) => {
const temp = row?.name ?? '';
return temp.toLowerCase().indexOf(term.toLowerCase()) > -1;
const featureStatus = featureStatusMap[row.name as string] || {};

// Concatenate all searchable fields
const searchableText = [
row.name || '',
row.killed ? 'killed' : 'live',
row.trafficType || '',
row.defaultTreatment || '',
featureStatus?.rolloutStatus?.name || '',
// Owners
featureStatus?.owners
?.map((owner: { id: string }) => ownersMap[owner.id]?.name || '')
.join(' ') || '',
// Tags
featureStatus?.tags
?.map((tag: { name: string }) => tag.name)
.join(' ') || '',
// Flag Sets
row.flagSets
?.map((f: { id: string }) => flagSetsMap[f.id]?.name || '')
.join(' ') || '',
]
.join(' ')
.toLowerCase();

return searchableText.indexOf(term.toLowerCase()) > -1;
},
customSort: (row1: Partial<TableData>, row2: Partial<TableData>) => {
const a = row1.name ?? '';
Expand Down Expand Up @@ -184,7 +219,9 @@ function FMEFeatureList() {
if (owner?.type === 'user') {
return `<a href="mailto:${owner.email}" target="_blank">${owner.name}</a>`;
} else if (owner?.type === 'group') {
return `<a href="${baseUrl}org/${orgId}/ws/${workspaceId}/admin/groups/details/${owner.id}" target="_blank"> ${owner.name} (Group) </a>`;
return isMigrated === 'true'
? `<a href="${harnessBaseUrl}ng/account/${harnessAccountId}/module/fme/settings/access-control/user-groups/${owner.id}" target="_blank"> ${owner.name} (Group) </a>`
: `<a href="${fmeBaseUrl}org/${orgId}/ws/${workspaceId}/admin/groups/details/${owner.id}" target="_blank"> ${owner.name} (Group) </a>`;
}
return owner?.name || '';
})
Expand Down Expand Up @@ -215,8 +252,36 @@ function FMEFeatureList() {
},
},
{
title: 'Rollout Status',
title: 'Tags',
field: 'col4',
customSort: (row1: Partial<TableData>, row2: Partial<TableData>) => {
const a =
featureStatusMap[row1.name as string]?.tags
?.map((tag: { name: String }) => tag.name)
.join(',') || '';
const b =
featureStatusMap[row2.name as string]?.tags
?.map((tag: { name: String }) => tag.name)
.join(',') || '';
return a.localeCompare(b);
},
type: 'string',
render: (row: Partial<TableData>) => {
const featureStatus = featureStatusMap[row.name as string];
return (
<Typography style={{ fontSize: 'small', color: 'black' }}>
<b>
{featureStatus?.tags
?.map((tag: { name: String }) => tag.name)
.join(',') || 'None'}{' '}
</b>
</Typography>
);
},
},
{
title: 'Rollout Status',
field: 'col5',
type: 'string',
customSort: (row1: Partial<TableData>, row2: Partial<TableData>) => {
const status1 =
Expand All @@ -241,7 +306,7 @@ function FMEFeatureList() {
},
{
title: 'Default Treatment',
field: 'col5',
field: 'col6',
type: 'string',
customSort: (row1: Partial<TableData>, row2: Partial<TableData>) => {
const a = row1.defaultTreatment?.toLowerCase() ?? '';
Expand All @@ -259,7 +324,7 @@ function FMEFeatureList() {

{
title: 'Flag Sets',
field: 'col6',
field: 'col7',
type: 'string',
customSort: (row1: Partial<TableData>, row2: Partial<TableData>) => {
const sets1 =
Expand Down Expand Up @@ -295,7 +360,7 @@ function FMEFeatureList() {
},
{
title: 'Created at',
field: 'col7',
field: 'col8',
type: 'date',
customSort: (row1: Partial<TableData>, row2: Partial<TableData>) => {
const date1 = row1.creationTime
Expand All @@ -315,7 +380,7 @@ function FMEFeatureList() {
},
{
title: 'Modified At',
field: 'col8',
field: 'col9',
type: 'date',
customSort: (row1: Partial<TableData>, row2: Partial<TableData>) => {
const date1 = row1.lastUpdateTime
Expand All @@ -335,7 +400,7 @@ function FMEFeatureList() {
},
{
title: 'Last Traffic Received',
field: 'col9',
field: 'col10',
type: 'date',
customSort: (row1: Partial<TableData>, row2: Partial<TableData>) => {
const date1 = row1.lastTrafficReceivedAt
Expand Down Expand Up @@ -367,7 +432,7 @@ function FMEFeatureList() {
'Could not find any Feature Flags, the bearer auth token is either missing or incorrect in app-config.yaml under proxy settings.';
} else if (!workspaceId && !orgId) {
description =
'Could not find any Feature Flags, please check your workspaceId and orgId configuration in catalog-info.yaml.';
'Could not find any Feature Flags, please check your annotation configuration in catalog-info.yaml.';
} else {
description =
'Could not find any Feature Flags, please check your configurations in catalog-info.yaml or check your token permissions.';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,42 @@ import { useEntity } from '@backstage/plugin-catalog-react';

export const useProjectSlugFromEntity = () => {
const { entity } = useEntity();
const workspaceIdAnnotation = 'harnessfme/projectId';
const orgIdAnnotation = 'harnessfme/accountId';
const workspaceId = entity.metadata.annotations?.[
workspaceIdAnnotation

const isMigratedAnnotation = 'harnessfme/isMigrated';
let isMigrated = entity.metadata.annotations?.[
isMigratedAnnotation
] as string;
const orgId = entity.metadata.annotations?.[orgIdAnnotation] as string;
if (isMigrated === '') {
isMigrated = 'false';
}
let workspaceId = '';
let orgId = '';
let harnessAccountId = '';
let harnessOrgId = '';
let harnessProjectId = '';

if (isMigrated === 'true') {
const myWorkAnnotation = 'harnessfme/mywork';
const myWorkUrl = entity.metadata.annotations?.[myWorkAnnotation] as string;
const urlAsArray = myWorkUrl.split('/');
workspaceId = urlAsArray[urlAsArray.length - 2];
orgId = urlAsArray[urlAsArray.length - 4];
harnessProjectId = urlAsArray[urlAsArray.length - 6];
harnessAccountId = urlAsArray[urlAsArray.length - 12];
harnessOrgId = urlAsArray[urlAsArray.length - 8];
} else {
const accountIdAnnotation = 'harnessfme/accountId';
orgId = entity.metadata.annotations?.[accountIdAnnotation] as string;
const projectIdAnnotation = 'harnessfme/projectId';
workspaceId = entity.metadata.annotations?.[projectIdAnnotation] as string;
}

return { workspaceId, orgId };
return {
workspaceId,
orgId,
harnessAccountId,
harnessOrgId,
harnessProjectId,
isMigrated,
};
};
Loading