Ways to move from hard-coded code for each environment to a universal build that can be used anywhere
Introduction
As you all know, Angular has its own tools for building an application for different environments
Configuring application environments
This is accomplished by creating and using the environment.<env>.ts
file for the appropriate environment in the build. These allow you to switch between settings for:
-
Development (
environment.ts
) -
Testing (
environment.test.ts
) -
Production (
environment.prod.ts
)
The main tasks of environment.ts
files are:
-
API settings. Each file can contain different URLs for API servers depending on the environment.
-
Optimization. The production file disables debugging features and enables optimization to improve performance.
-
Environment variables. Easily manage environment variables such as API keys and flags to activate or deactivate functions.
And everything seems to be fine — a different file for each environment
Problem
Imagine, as the number of environments grows, you need to:
-
Create a separate
environment.<env>.ts file
each time. -
Create a separate build-configuration and specify
fileReplacements
-
Add this build-configuration to serve-build
-
Add the command
"my-app.build.<env>": "ng build — configuration <env>”
topackage.json
So every time
I’m not speaking about e2e-tests in pre-prod-environment or dedicated builds for feature-branch.
And for what? — To use a template command in CI
- run: npm run my-app:build:${{ SOME_VAR.ENV }}
The notional pipelines of such an application can be depicted as follows
And you can say — “this is basic scaling and CI independence from env. If we want to use TEST1, just pass ENV=TEST1”.
No, my young architect, this is wrong
. Why? — Because your application knows about all the environments in which it is used, and keeping track of every configuration is a problem:
-
Do you want to add 1 new parameter to the config? — you need to update each file, plus you need to know what value is needed for each env.
-
Want to change a parameter for an application in some environment? — Be kind enough to go to the repositories, update the file and run it
There are so many examples of this, and every time there are scalability and support issues
Disadvantages of using environments.ts files
-
Support for all
environments.<env>.ts
files is required -
Creating alternative builds for each env in
angular.json
-
Creating duplicate docker images with only a few parameters, resulting inefficient registry space utilization
-
Necessity to run a full pipeline for each
-
Tests are performed on an assembly that will not be provided to the user
-
Lack of flexibility to change a parameter for an individual environment
-
Inability to share the application as a HeadLess solution
Task Statement
Main points to achieve:
-
Ensure that the application and docker image are built only 1 time.
-
Our application should be able to be configured for different ENVs, and define only the interface of the variables it needs
-
To separate the development space and work with the application build, we need to keep the source files
environment.ts
andenvironment.prod.ts
As a result, we should get the following scheme for using and deploying the application
Where:
-
Envless Build pipeline is a pipeline that runs only once, builds the production build of the application, and saves it as a docker image in the registry
-
Deploy pipeline is a release pipeline that already knows for which ENV the application should be deployed.
The application configuration is pulled back until the last moment closer to the release part
Solution 1 — Get config from the server
To implement this solution, it is necessary to have an API-endpoint server within which all the necessary config can be obtained
Scheme of work
Implementation
- Let’s declare a configure interface and a token for its provisioning within the application. Also, we’ll create a default value for the initial state
export interface IAppConfig {
apiHost: string,
imageHost: string
titleApp: string
}
export const APP_CONFIG_DEFAULT:IAppConfig = {
apiHost: 'https://my-backend.com',
imageHost: 'https://image-service.com',
titleApp: 'Production Angular EnvLess App'
}
export const APP_CONFIG_TOKEN: InjectionToken<IAppConfig> = new InjectionToken<IAppConfig>(
'APP_CONFIG_TOKEN'
)
- Create functions that will request the config and provide it. In case of server error, provide the application with default settings for valid operation
function loadConfig(): Promise<IAppConfig> {
// or relative path /app/config
return fetch('http://localhost:3000/app/config').then(
(res) => res.json(),
() => APP_CONFIG_DEFAULT
)
}
export async function bootstrapApplicationWithConfig(
rootComponent: Type<unknown>,
appConfig?: ApplicationConfig
): Promise<ApplicationRef> {
return bootstrapApplication(rootComponent, {
providers: [
...appConfig?.providers || [],
{
provide: APP_CONFIG_TOKEN,
useValue: await loadConfig()
},
]
})
}
- Replace the native
bootstrapApplication
function with a newbootstrapApplicationWithConfig
function
// bootstrapApplication(AppComponent, appConfig)
bootstrapApplicationWithConfig(AppComponent, appConfig)
.catch((err) => console.error(err));
- The application configuration server will be a simple configuration on
express
app.get('/app/config', (req, res) => {
res.json({
apiHost: 'https://my-backend.<ENV>.com',
imageHost: 'https://image-service.<ENV>.com',
titleApp: '<Env> - Angular EnvLess App'
});
});
Run-time verification
Since the application is configured in run-time, it is not a problem to check its operation even in a local environment, knowing the required URL.
At the moment of application startup, we get the config on request without any problems. We can use this config for other application services that use other API URLs for requests
In case of a response error from the server, the default value will be used and the application will continue working
Advantages and disadvantages
In addition to fulfilled task conditions, there is other advantages of using this approach:
-
The application is configured by runtime and does not require redeployment
-
The configuration is loaded before the application starts, so you can use parallel requests via
APP_INITIALIZER
tokens without worrying about the order in which the config is received -
In e2e-tests, it is easy to intercept the request and give the configuration file for the test
But there is a downside:
-
You need to know the URL to get the config or have the same host for both front- and back-end application
-
The response from the server can be long, which can affect the user’s expectation
-
It is necessary to have a FALLBACK value in case of a request error
-
It is necessary to have a dedicated database with config for each ENV
-
It is necessary to have an API for reading and modifying the config by an administrator with dedicated access rights
-
Due to run-time configuration, there is an increased chance of errors and the config needs to be validated in the front-end application
-
Configuration support is provided by backend developers and DevOps
If we don’t touch the basic build pipeline, is there any possibility to keep the scheme with getting the config, but no longer depend on the server considering its disadvantages? — Yes, you can configure a docker image
Solution 2 — Configuring a docker image
The essence of this solution is simple — instead of requesting a remote server to get the config, there will be a request to the file directory where the Frontend application is located
The configuration file will be created during the source docker image retrieval stage, replacing the default config.json
. The default directory in which the front-end application executes requests is assets
or public
.
The configuration file will be created on the ENV variables that were specified when the deploy pipeline was started. If the ENV variable is not found — the default value will be used
Scheme of work
The solution is elegant but will require skills not only in frontend, but also in DevOps and CI-scripts
You will need to implement this scheme of work to “update” the config.json
file
Deploy Pipeline
Implementation
As opposed to getting config.json from the server. We need the keys in the config to match the ENV variable name when the config is updated
{
"APP_ENV_API_HOST": "https://local.my-backend.com",
"APP_ENV_API_IMAGE_HOST": "https://local.image-service.com",
"APP_ENV_TITLE_APP": "Local - Angular EnvLess App"
}
- Config request function with updated URL
function loadConfig(): Promise<IAppConfig> {
// relative host
// public or assets path
return fetch('/config.json').then(
(res) => res.json(),
)
}
- Example script to create a new
config.json
and update docker-image
#!/bin/bash
set -x
set -e
# EXAMPLE - ENVS FOR CONFIG
APP_ENV_API_HOST="https://<ENV>.my-backend.com"
APP_ENV_API_IMAGE_HOST="https://<ENV>.image-service.com"
# SETTINGS
PORT=4110
NGINX_PORT=80
CONTAINER_NAME="angular-envless-container"
IMAGE="angular-envless"
NEW_IMAGE="patched-angular-envless"
CONFIG_NAME="config.json"
APP_PATH="/usr/share/nginx/html"
#Step 1
temp_container_run(){
docker run -it -d -p $PORT:$NGINX_PORT --name $CONTAINER_NAME $IMAGE
}
#Step 2
temp_container_get_config(){
docker cp $CONTAINER_NAME:$APP_PATH/$CONFIG_NAME ./$CONFIG_NAME
}
#Step 3
create_config_json(){
temp_container_get_config
if [[ ! -f "./$CONFIG_NAME" ]]; then
echo "Config file not found in the specified directory."
temp_container_stop
temp_container_rm
return 1
fi
# Extracting keys and values from JSON
KEY_VALUES=$(awk -F '[:,]' '/:/{gsub(/"| /,""); print $1 "="" $2 """}' "./$CONFIG_NAME")
# Creating a new JSON object
PROD_CONFIG="{"
# Passing through keys and values
for PAIR in $KEY_VALUES; do
# Separating key and value
KEY=$(echo $PAIR | cut -d '=' -f 1)
DEFAULT_VALUE=$(echo $PAIR | cut -d '=' -f 2 | sed 's/,$//')
# Check if there is a value in the environment variables
VALUE=${!KEY}
# If there is no environment variable, we use the value from the source file
if [[ -z "$VALUE" ]]; then
VALUE=$DEFAULT_VALUE
else
VALUE=""$VALUE""
fi
# Add key and value to JSON object
PROD_CONFIG+=""$KEY":$VALUE,"
done
# Remove the last comma and close the JSON object
PROD_CONFIG=${PROD_CONFIG%,}
PROD_CONFIG+="}"
# Saving the result to a file
echo "$PROD_CONFIG" > "./$CONFIG_NAME"
echo "Config updated successfully and saved to ./$CONFIG_NAME"
}
#Step 4
temp_container_upsert_config(){
docker cp ./$CONFIG_NAME $CONTAINER_NAME:$APP_PATH/$CONFIG_NAME
}
#Step 5
temp_container_commit(){
docker commit --pause $CONTAINER_NAME $NEW_IMAGE
}
#Step 6.1
temp_container_stop(){
docker stop $CONTAINER_NAME
}
#Step 6.2
temp_container_rm(){
docker rm $CONTAINER_NAME
}
main(){
temp_container_run
create_config_json
temp_container_upsert_config
temp_container_commit
temp_container_stop
temp_container_rm
}
main
Run-time verification
After running this script, all you need to do is run the docker image in the container and make sure that the environment variables are applied to the config
The code works fine, given that the APP_ENV_TITLE_APP
variable was not passed when the docker-image was configured
Advantages and disadvantages
Thanks to this approach, the server’s influence on front-application configuration is reduced. In addition to the advantages of configuration through the server, we get:
-
The configuration is available immediately and delays in getting it are minimal
-
No FALLBACK value is required in case of a request error
-
No API or external administration over the config is required.
-
Reliability. The assembly cannot be broken in run-time
-
No need to create a separate database for each environment
But if you look at it from the other side, there are some disadvantages:
-
Increased complexity of the application’s deployment infrastructure
-
Separate Pipeline Deployment is required
-
Need to be supported and validated by DevOps-engineers
-
Need to know the right ENV-variables and set a pre-defined list to configure the application at the time the deployment starts
In conclusion
The solutions I have presented are only suitable if you need flexibility and independent management of your Frontend application. If you realize that your current application does not require flexible configuration, your DevOps engineers effectively manage docker-registry memory or you do not plan to create a headless application — you can use environment.ts
files as before.
In some cases, when you don’t know the possible implementations and uses of an application at the start of development, this approach can save a lot of time in the future, and give you full control over build management
Source link
lol