Skip to content

Commit

Permalink
[MDS-5293] Added feature flag support to Minespace, Core, and Core AP…
Browse files Browse the repository at this point in the history
…I using Flagsmith (#2647)

* component layout for home page. Search bar will require some changes so just remade in functional/TS format

* actions, reducers, selectors, etc, for getting global mine alerts. Change route to point to new home page (will rename later)

* Revert "component layout for home page. Search bar will require some changes so just remade in functional/TS format"

This reverts commit 3ab20a7.

* Revert "actions, reducers, selectors, etc, for getting global mine alerts. Change route to point to new home page (will rename later)"

This reverts commit 7339823.

* MDS-5293 Added support for flagsmith feature flags to core and minespace

* Updated server / added provider

* MDS-5293 Added backend support for feature flags

* MDS-5293 Added feature flag documentation

* MDS-5293 Fixed fe tests

* Removed old feature flags

* MDS-5293 Fixed pipeline Flagsmith props

* Fixed cypress flagsmith envs

* MDS-5293 Added flagsmith envs to env config

---------

Co-authored-by: Tara Epp <[email protected]>
Co-authored-by: simensma-fresh <[email protected]>
  • Loading branch information
3 people authored Sep 6, 2023
1 parent 24164bc commit 3a6fa4d
Show file tree
Hide file tree
Showing 75 changed files with 1,018 additions and 1,758 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/core-web.unit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ jobs:
CYPRESS_KEYCLOAK_CLIENT_ID: ${{ secrets.CYPRESS_KEYCLOAK_CLIENT_ID }}
CYPRESS_KEYCLOAK_IDP_HINT: ${{ secrets.CYPRESS_KEYCLOAK_IDP_HINT }}
CYPRESS_KEYCLOAK_RESOURCE: ${{ secrets.CYPRESS_KEYCLOAK_RESOURCE }}
CYPRESS_FLAGSMITH_URL: ${{ secrets.CYPRESS_FLAGSMITH_URL }}
CYPRESS_FLAGSMITH_KEY: ${{ secrets.CYPRESS_FLAGSMITH_KEY }}
- name: Upload cypress video
uses: actions/upload-artifact@v3
if: always()
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/minespace.unit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ jobs:
CYPRESS_KEYCLOAK_CLIENT_ID: ${{ secrets.CYPRESS_KEYCLOAK_CLIENT_ID }}
CYPRESS_KEYCLOAK_IDP_HINT: ${{ secrets.CYPRESS_KEYCLOAK_IDP_HINT }}
CYPRESS_KEYCLOAK_RESOURCE: ${{ secrets.CYPRESS_KEYCLOAK_RESOURCE }}
CYPRESS_FLAGSMITH_URL: ${{ secrets.CYPRESS_FLAGSMITH_URL }}
CYPRESS_FLAGSMITH_KEY: ${{ secrets.CYPRESS_FLAGSMITH_KEY }}

- name: Upload cypress video
uses: actions/upload-artifact@v3
Expand Down
120 changes: 120 additions & 0 deletions docs/feature_flags.mds
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Feature Flags

MDS Supports feature flags both for our frontend and backend apps using [Flagsmith](https://docs.flagsmith.com).

## Frontend

A couple of options exists to check for a feature flag in core-web and minespace-web.

### Defining feature flag

Feature flags for use in core / minespace have to be defined in the `Feature` enum located in `featureFlag.ts` in the common package.
The values of this enum, must match the name of a feature flag defined in Flagsmith.

```typescript
export enum Feature {
MAJOR_PROJECT_ARCHIVE_FILE = "major_project_archive_file",
DOCUMENTS_REPLACE_FILE = "major_project_replace_file",
MAJOR_PROJECT_ALL_DOCUMENTS = "major_project_all_documents",
MAJOR_PROJECT_DECISION_PACKAGE = "major_project_decision_package",
FLAGSMITH = "flagsmith",
TSF_V2 = "tsf_v2",
}
```

### Using `useFeatureFlag` hook

Preferred method if using feature flag in a functional React component.

```typescript
import { useFeatureFlag } from "@common/providers/featureFlags/useFeatureFlag";
import { Feature } from "@mds/common";

const ThisIsAReactComponent = () => {
const { isFeatureEnabled } = useFeatureFlag();

return (
{isFeatureEnabled(Feature.TSF_V2)? <p>SuperSecretFeature</p>: <p>Sorry, you can't access this</p>}
);
};
```

### Using `withFeatureFlag` HOC

Alternative method if using feature flag in a React component and you cannot use hooks (for example in class components).

```typescript
import withFeatureFlag from "@common/providers/featureFlags/withFeatureFlag";
import { Feature } from "@mds/common";

class ThisIsAReactComponent {
render() {
return (
{props.isFeatureEnabled(Feature.TSF_V2)? <p>SuperSecretFeature</p>: <p>Sorry, you can't access this</p>}
);
}
};

export default withFeatureFlag(ThisIsAReactComponent);
```

### Using FeatureFlagGuard
Need to restrict a route based on a feature flag?

You can use the `FeatureFlagGuard` and pass along the feature you want to check for.
If it's not enabled, you get a nice little "you don't have access" notice.

```typescript
import { Feature } from "@mds/common";
import FeatureFlagGuard from "@/components/common/featureFlag.guard";


const DamsPage: React.FC<DamsPageProps> = (props) => {
return (
<div>
ALL THE DAMS
</div>
);
};

const mapStateToProps = (state: RootState) => ({
initialValues: getDam(state),
tsf: getTsf(state),
});

const mapDispatchToProps = (dispatch) =>
bindActionCreators(
{ createDam, updateDam, fetchMineRecordById, storeTsf, storeDam, submit },
dispatch
);

export default compose(
connect(mapStateToProps, mapDispatchToProps),
)(withRouter(FeatureFlagGuard(Feature.TSF_V2)(DamsPage)));
```

### Using FeatureFlag directly (discouraged)

If you need access to a feature flag outside of a react context you can use `featureFlag.ts` directly.
Please use the other methods above as far as you can.

```typescript
import { isFeatureEnabled } from @mds/common;
import { Feature } from "@mds/common";

console.log(isFeatureEnabled(Feature.TSF_V2));

```

## Core API

How to use:
1. Define a feature flag in the `Feature` enum (feature_flag.py). The value of the enum must match a feature flag defined in Flagsmith.
2. You can use the `is_feature_enabled` function to check if the given flag is enabled.

```python
from app.api.utils.feature_flag import is_feature_enabled, Feature

if is_feature_enabled(Feature.TSF_V2):
# Do something if TSF_V2 feature is enabled
```
6 changes: 5 additions & 1 deletion services/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@
"dependencies": {
"@babel/runtime": "7.15.3",
"@babel/runtime-corejs3": "7.15.3",
"flagsmith": "3.19.1",
"query-string": "5.1.1",
"react": "16.13.1",
"ts-loader": "8.4.0"
},
"peerDependencies": {
"react": "16.13.1",
"react-dom": "16.13.1"
},
"devDependencies": {
"@babel/core": "7.15.0",
"@babel/plugin-proposal-class-properties": "7.14.5",
Expand Down
25 changes: 24 additions & 1 deletion services/common/src/constants/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const DEFAULT_ENVIRONMENT = {
keycloak_clientId: "mines-digital-services-mds-public-client-4414",
keycloak_idpHint: "test",
keycloak_url: "https://test.loginproxy.gov.bc.ca/auth",
flagsmithKey: "4Eu9eEMDmWVEHKDaKoeWY7",
flagsmithUrl: "https://mds-flags-dev.apps.silver.devops.gov.bc.ca/api/v1/",
};

export const ENVIRONMENT = {
Expand All @@ -19,6 +21,9 @@ export const ENVIRONMENT = {
matomoUrl: "<MATOMO_URL>",
filesystemProviderUrl: "<FILESYSTEM_PROVIDER_URL>",
environment: "<ENV>",
flagsmithKey: "<FLAGSMITH_KEY>",
flagsmithUrl: "<FLAGSMITH_URL>",
_loaded: false,
};

export const KEYCLOAK = {
Expand All @@ -43,7 +48,15 @@ export const KEYCLOAK = {
userInfoURL: "<URL>",
};

export function setupEnvironment(apiUrl, docManUrl, filesystemProviderUrl, matomoUrl, environment) {
export function setupEnvironment(
apiUrl,
docManUrl,
filesystemProviderUrl,
matomoUrl,
environment,
flagsmithKey,
flagsmithUrl
) {
if (!apiUrl) {
throw new Error("apiUrl Is Mandatory");
}
Expand All @@ -63,12 +76,22 @@ export function setupEnvironment(apiUrl, docManUrl, filesystemProviderUrl, matom
if (!environment) {
throw new Error("environment Is Mandatory");
}
if (!flagsmithKey) {
throw new Error("flagsmithKey Is Mandatory");
}
if (!flagsmithUrl) {
throw new Error("flagsmithUrl Is Mandatory");
}

ENVIRONMENT.apiUrl = apiUrl;
ENVIRONMENT.docManUrl = docManUrl;
ENVIRONMENT.filesystemProviderUrl = filesystemProviderUrl;
ENVIRONMENT.matomoUrl = matomoUrl;
ENVIRONMENT.environment = environment || "development";
ENVIRONMENT.flagsmithKey = flagsmithKey;
ENVIRONMENT.flagsmithUrl = flagsmithUrl;

ENVIRONMENT._loaded = true;
}

export function setupKeycloak(
Expand Down
27 changes: 16 additions & 11 deletions services/common/src/utils/featureFlag.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { detectProdEnvironment as IN_PROD } from "./environmentUtils";
import flagsmith from "flagsmith";

// Name of feature flags. These correspond to feature flags defined in flagsmith.
export enum Feature {
MAJOR_PROJECT_ARCHIVE_FILE,
DOCUMENTS_REPLACE_FILE,
MAJOR_PROJECT_ARCHIVE_FILE = "major_project_archive_file",
DOCUMENTS_REPLACE_FILE = "major_project_replace_file",
MAJOR_PROJECT_ALL_DOCUMENTS = "major_project_all_documents",
MAJOR_PROJECT_DECISION_PACKAGE = "major_project_decision_package",
FLAGSMITH = "flagsmith",
TSF_V2 = "tsf_v2",
}

const Flags = {
[Feature.MAJOR_PROJECT_ARCHIVE_FILE]: !IN_PROD(),
[Feature.DOCUMENTS_REPLACE_FILE]: !IN_PROD(),
export const initializeFlagsmith = async (flagsmithUrl, flagsmithKey) => {
await flagsmith.init({
api: flagsmithUrl,
environmentID: flagsmithKey,
cacheFlags: true,
enableAnalytics: true,
});
};

/**
Expand All @@ -16,9 +25,5 @@ const Flags = {
* @returns true if the given feature is enabled
*/
export const isFeatureEnabled = (feature: Feature) => {
if (feature in Flags) {
return Flags[feature];
}

return false;
return flagsmith.hasFeature(feature);
};
3 changes: 2 additions & 1 deletion services/common/webpack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ module.exports = (opts) => {
const mode = opts.development ? "development" : "production";
console.log(`\n============= Webpack Mode : ${mode} =============\n`);
return {
watch: mode === "development",
mode,
entry: "./src/index.ts",
output: {
Expand All @@ -23,7 +24,7 @@ module.exports = (opts) => {
// Increase file change poll interval to reduce
// CPU usage on some operating systems.
poll: 2500,
ignored: /node_modules/,
ignored: /node_modules|dist/,
},
module: {
rules: [
Expand Down
4 changes: 4 additions & 0 deletions services/core-api/.env-example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ VFCBC_TOKEN_URL=
VFCBC_CLIENT_ID=mms_srv1
VFCBC_CLIENT_SECRET=

FLAGSMITH_URL=https://mds-flags-dev.apps.silver.devops.gov.bc.ca/api/v1/
FLAGSMITH_KEY=4Eu9eEMDmWVEHKDaKoeWY7
FLAGSMITH_ENABLE_LOCAL_EVALUTION=false

DOCUMENT_MANAGER_URL=http://document_manager_backend:5001

CACHE_REDIS_HOST=redis
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
from app.config import Config
from app.api.activity.utils import trigger_notification

from app.api.utils.feature_flag import is_feature_enabled, Feature


class MinePartyApptResource(Resource, UserMixin):
parser = CustomReqparser()
Expand Down Expand Up @@ -202,8 +204,7 @@ def post(self, mine_party_appt_guid=None):
data.get('mine_party_appt_type_code')).description
raise BadRequest(f'Date ranges for {mpa_type_name} must not overlap')

if Config.ENVIRONMENT_NAME != 'prod':
# TODO: Remove this once TSF functionality is ready to go live
if is_feature_enabled(Feature.TSF_V2):
if mine_party_appt_type_code == "EOR":

trigger_notification(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from app.api.mines.mine.models.mine import Mine
from app.api.services.document_manager_service import DocumentManagerService
from app.config import Config
from app.api.utils.feature_flag import is_feature_enabled, Feature

class ProjectSummaryDocumentUploadResource(Resource, UserMixin):
@api.doc(
Expand All @@ -26,9 +27,7 @@ def post(self, project_guid, project_summary_guid):
if not mine:
raise NotFound('Mine not found')

# FEATURE FLAG: DOCUMENTS_REPLACE_FILE
if Config.ENVIRONMENT_NAME != 'prod':
# TODO: Remove the ENV check and else part when 5273 is ready to go live
if is_feature_enabled(Feature.MAJOR_PROJECT_REPLACE_FILE):
return DocumentManagerService.validateFileNameAndInitializeFileUploadWithDocumentManager(
request, mine, project_guid, 'project_summaries')
else:
Expand Down
24 changes: 24 additions & 0 deletions services/core-api/app/api/utils/feature_flag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from enum import Enum
from flagsmith import Flagsmith
from app.config import Config
from flask import current_app


class Feature(Enum):
TSF_V2='tsf_v2'
MAJOR_PROJECT_REPLACE_FILE='major_project_replace_file'

def __str__(self):
return self.value

flagsmith = Flagsmith(
environment_id = Config.FLAGSMITH_KEY,
api = Config.FLAGSMITH_URL,
)

def is_feature_enabled(feature):
try:
return flagsmith.has_feature(feature) and flagsmith.feature_enabled(feature)
except Exception as e:
current_app.logger.error(f'Failed to look up feature flag for: {feature}. ' + str(e))
return False
8 changes: 8 additions & 0 deletions services/core-api/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def JWT_ROLE_CALLBACK(jwt_dict):
NROS_NOW_CLIENT_ID = os.environ.get('NROS_NOW_CLIENT_ID', None)
NROS_NOW_TOKEN_URL = os.environ.get('NROS_NOW_TOKEN_URL', None)
NROS_NOW_CLIENT_SECRET = os.environ.get('NROS_NOW_CLIENT_SECRET', None)


# Cache settings
CACHE_TYPE = os.environ.get('CACHE_TYPE', 'redis')
Expand All @@ -122,6 +123,13 @@ def JWT_ROLE_CALLBACK(jwt_dict):
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {'pool_timeout': 300, 'max_overflow': 20}

# Flagsmith
FLAGSMITH_URL=os.environ.get('FLAGSMITH_URL', 'https://mds-flags-dev.apps.silver.devops.gov.bc.ca/api/v1/')
FLAGSMITH_KEY=os.environ.get('FLAGSMITH_KEY', '4Eu9eEMDmWVEHKDaKoeWY7')

# Enable flag caching and evalutation. If set to True, FLAGSMITH_KEY must be set to a server side FLAGSMITH_KEY
FLAGSMITH_ENABLE_LOCAL_EVALUTION=os.environ.get('FLAGSMITH_ENABLE_LOCAL_EVALUTION', 'false') == 'true'

# NROS
NROS_CLIENT_SECRET = os.environ.get('NROS_CLIENT_SECRET', None)
NROS_CLIENT_ID = os.environ.get('NROS_CLIENT_ID', None)
Expand Down
1 change: 1 addition & 0 deletions services/core-api/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
cached-property==1.5.1
factory-boy==2.12.0
flagsmith==2.0.1
Flask==1.1.2
Flask-Caching==1.8.0
Flask-Cors==3.0.9
Expand Down
Loading

0 comments on commit 3a6fa4d

Please sign in to comment.