Skip to content

Commit

Permalink
Add envsubst
Browse files Browse the repository at this point in the history
  • Loading branch information
louislam committed Dec 9, 2023
1 parent 787564f commit 958a342
Show file tree
Hide file tree
Showing 6 changed files with 263 additions and 170 deletions.
139 changes: 139 additions & 0 deletions backend/envsubst.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Original Source: https://github.com/inventage/envsubst/blob/main/src/utils.js
* MIT License
* Copyright (c) 2021 Inventage AG
*
* Copy this file, because
*/
import escapeStringRegexp from "escape-string-regexp";

Check failure on line 8 in backend/envsubst.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18.17.1)

Cannot find module 'escape-string-regexp' or its corresponding type declarations.
import { LooseObject } from "./util-common";

const toLowerKeys = (object : LooseObject) => {
return Object.keys(object).reduce((accumulator : LooseObject, key) => {
accumulator[key.toLowerCase()] = object[key];
return accumulator;
}, {});
};

/**
* Regex pattern with an optional prefix.
*
* @see https://regex101.com/r/M3dVAW/1
* @param prefix
* @returns {string}
*/
const variableRegexPattern = (prefix = ""): string => {
return `\\\${(${prefix ? escapeStringRegexp(prefix) : ""}\\w+)(:-([^}]*))?}`;
};

/**
* Regex pattern that wraps the variable regex pattern with a window variable statement:
*
* window['${VAR}'] or window["${VAR}"]
*
* @see https://regex101.com/r/ND057d/1
* @param prefix
* @returns {string}
*/
const windowVariableRegexPattern = (prefix = ""): string => {
return `(window\\[['"]{1})?${variableRegexPattern(prefix)}(['"]{1}\\])?`;
};

/**
* Replaces all variable placeholders in the given string with either variable values
* found in the variables parameter OR with the given default in the variable string.
*
* @param {string} string
* @param {object} variables
* @param {string} prefix
* @param {boolean} trimWindow
* @param {boolean} ignoreCase
* @returns {Promise<unknown[]>}
*/
const replaceVariables = (string: string, variables: object = {}, prefix: string = "", trimWindow: boolean = false, ignoreCase: boolean = false): Promise<unknown[]> =>
new Promise(resolve => {
resolve(replaceVariablesSync(string, variables, prefix, trimWindow, ignoreCase));
});

/**
* Replaces all variable placeholders in the given string with either variable values
* found in the variables parameter OR with the given default in the variable string.
*
* @param {string} string
* @param {object} variables
* @param {string} prefix
* @param {boolean} trimWindow
* @param {boolean} ignoreCase
* @returns {unknown[]}
*/
const replaceVariablesSync = (string : string, variables: LooseObject = {}, prefix: string = "", trimWindow: boolean = false, ignoreCase: boolean = false): unknown[] => {
const regex = new RegExp(trimWindow ? windowVariableRegexPattern(prefix) : variableRegexPattern(prefix), ignoreCase ? "gmi" : "gm");
const matches = [ ...string.matchAll(regex) ];
const lowercaseVariables = toLowerKeys(variables);

let replaced = string;
const replacements : LooseObject[] = [];
for (const match of matches) {
if (trimWindow) {
const [ original, windowStart, name, , fallback, windowEnd ] = match;

// Bail if the match does not contain `^window[`
if (!windowStart) {
continue;
}

const valueStartQuote = windowStart.replace("window[", "");
const valueEndQuote = windowEnd.replace("]", "");
const withoutWindow = original.replace(windowStart, "").replace(windowEnd, "");

let value;
if (ignoreCase) {
value = Object.hasOwnProperty.call(lowercaseVariables || {}, name.toLowerCase()) ? lowercaseVariables[name.toLowerCase()] : fallback;
} else {
value = Object.hasOwnProperty.call(variables || {}, name) ? variables[name] : fallback;
}

if (value !== undefined) {
const quotedValue = `${valueStartQuote}${value}${valueEndQuote}`;
const replacement = replacements.find(r => r.from === original && r.to === quotedValue);
if (replacement) {
replacement.count = replacement.count + 1;
} else {
replacements.push({ from: original,
to: quotedValue,
count: 1 });
}

replaced = replaced.split(original).join(withoutWindow.split(withoutWindow).join(quotedValue));
}
} else {
const [ original, name, , fallback ] = match;

let value : string;
if (ignoreCase) {
value = Object.hasOwnProperty.call(lowercaseVariables || {}, name.toLowerCase()) ? lowercaseVariables[name.toLowerCase()] : fallback;
} else {
value = Object.hasOwnProperty.call(variables || {}, name) ? variables[name] : fallback;
}

if (value !== undefined) {
const replacement = replacements.find(r => r.from === original && r.to === value);
if (replacement) {
replacement.count = replacement.count + 1;
} else {
replacements.push({ from: original,
to: value,
count: 1 });
}

replaced = replaced.split(original).join(value);
}
}
}

return [ replaced, replacements ];
};


Check warning on line 137 in backend/envsubst.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18.17.1)

More than 1 blank line not allowed

Check warning on line 137 in backend/envsubst.ts

View workflow job for this annotation

GitHub Actions / ci (ARM64, 18.17.1)

More than 1 blank line not allowed

export { variableRegexPattern, replaceVariables, replaceVariablesSync };
57 changes: 17 additions & 40 deletions backend/util-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
* Common utilities for backend and frontend
*/
import yaml, { Document, Pair, Scalar } from "yaml";
import dotenv, { DotenvParseOutput } from "dotenv";
// @ts-ignore
import envsub from "envsub";
import { DotenvParseOutput } from "dotenv";

// Init dayjs
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import relativeTime from "dayjs/plugin/relativeTime";
// @ts-ignore
import { replaceVariablesSync } from "@inventage/envsubst";

dayjs.extend(utc);
dayjs.extend(timezone);
Expand Down Expand Up @@ -345,13 +345,18 @@ export function parseDockerPort(input : string, defaultHostname : string = "loca
};
}

export function envsubst(string : string, variables : LooseObject) : string {
return replaceVariablesSync(string, variables)[0];
}

/**
* Traverse all values in the yaml and for each value, if there are template variables, replace it environment variables
* Emulates the behavior of how docker-compose handles environment variables in yaml files
* @param content Yaml string
* @param env Environment variables
* @returns string Yaml string with environment variables replaced
*/
export function renderYAML(content : string, env : DotenvParseOutput) : string {
export function envsubstYAML(content : string, env : DotenvParseOutput) : string {
const doc = yaml.parseDocument(content);
if (doc.contents) {
// @ts-ignore
Expand All @@ -362,53 +367,25 @@ export function renderYAML(content : string, env : DotenvParseOutput) : string {
return doc.toString();
}

export function traverseYAML(pair : Pair, env : DotenvParseOutput) : void {
/**
* Used for envsubstYAML(...)
* @param pair
* @param env
*/
function traverseYAML(pair : Pair, env : DotenvParseOutput) : void {
// @ts-ignore
if (pair.value && pair.value.items) {
// @ts-ignore
for (const item of pair.value.items) {
if (item instanceof Pair) {
traverseYAML(item, env);
} else if (item instanceof Scalar) {
item.value = "CAN_READ!";
item.value = envsubst(item.value, env);
}
}
// @ts-ignore
} else if (pair.value && typeof(pair.value.value) === "string") {
// @ts-ignore
pair.value.value = "CAN_READ!";
pair.value.value = envsubst(pair.value.value, env);
}
}

const config = dotenv.parse(`TEST=123
`);

let test = renderYAML(`
x-dockge:
icon: null
author: null
repo: ""
a: 1\${C}
urls:
- https://louislam.net:3000/test.php?aaa=232&bbb=23
- http://uptime.kuma.pet
- ""
version: "3.8"
services:
nginx:
image: nginx:latest
restart: unless-stopped
ports:
- 8080\${C:-:}80
environment: []
networks: []
depends_on: []
nginx2:
image: nginx:latest
restart: unless-stopped
networks:
asdsd: {}
`, config);

console.log(test);

23 changes: 18 additions & 5 deletions frontend/src/components/Container.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<div v-if="!isEditMode">
<span class="badge me-1" :class="bgStyle">{{ status }}</span>

<a v-for="port in service.ports" :key="port" :href="parsePort(port).url" target="_blank">
<a v-for="port in envsubstService.ports" :key="port" :href="parsePort(port).url" target="_blank">
<span class="badge me-1 bg-secondary">{{ parsePort(port).display }}</span>
</a>
</div>
Expand Down Expand Up @@ -213,16 +213,29 @@ export default defineComponent({
jsonObject() {
return this.$parent.$parent.jsonConfig;
},
envsubstJSONConfig() {
return this.$parent.$parent.envsubstJSONConfig;
},
envsubstService() {
if (!this.envsubstJSONConfig.services[this.name]) {
return {};
}
return this.envsubstJSONConfig.services[this.name];
},
imageName() {
if (this.service.image) {
return this.service.image.split(":")[0];
if (this.envsubstService.image) {
return this.envsubstService.image.split(":")[0];
} else {
return "";
}
},
imageTag() {
if (this.service.image) {
let tag = this.service.image.split(":")[1];
if (this.envsubstService.image) {
let tag = this.envsubstService.image.split(":")[1];
if (tag) {
return tag;
Expand Down
47 changes: 31 additions & 16 deletions frontend/src/pages/Compose.vue
Original file line number Diff line number Diff line change
Expand Up @@ -231,14 +231,15 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
COMBINED_TERMINAL_COLS,
COMBINED_TERMINAL_ROWS,
copyYAMLComments,
copyYAMLComments, envsubstYAML,
getCombinedTerminalName,
getComposeTerminalName,
PROGRESS_TERMINAL_ROWS,
RUNNING
} from "../../../backend/util-common";
import { BModal } from "bootstrap-vue-next";
import NetworkInput from "../components/NetworkInput.vue";
import dotenv from "dotenv";
const template = `version: "3.8"
services:
Expand Down Expand Up @@ -277,6 +278,7 @@ export default {
return {
editorFocus: false,
jsonConfig: {},
envsubstJSONConfig: {},
yamlError: "",
processing: true,
showProgressTerminal: false,
Expand Down Expand Up @@ -645,28 +647,41 @@ export default {
return highlight(code, languages.docker_env);
},
yamlCodeChange() {
try {
let doc = parseDocument(this.stack.composeYAML);
if (doc.errors.length > 0) {
throw doc.errors[0];
}
yamlToJSON(yaml) {
let doc = parseDocument(yaml);
if (doc.errors.length > 0) {
throw doc.errors[0];
}
const config = doc.toJS() ?? {};
const config = doc.toJS() ?? {};
// Check data types
// "services" must be an object
if (!config.services) {
config.services = {};
}
// Check data types
// "services" must be an object
if (!config.services) {
config.services = {};
}
if (Array.isArray(config.services) || typeof config.services !== "object") {
throw new Error("Services must be an object");
}
if (Array.isArray(config.services) || typeof config.services !== "object") {
throw new Error("Services must be an object");
}
return {
config,
doc,
};
},
yamlCodeChange() {
try {
let { config, doc } = this.yamlToJSON(this.stack.composeYAML);
this.yamlDoc = doc;
this.jsonConfig = config;
let env = dotenv.parse(this.stack.composeENV);
let envYAML = envsubstYAML(this.stack.composeYAML, env);
this.envsubstJSONConfig = this.yamlToJSON(envYAML).config;
clearTimeout(yamlErrorTimeout);
this.yamlError = "";
} catch (e) {
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
},
"dependencies": {
"@homebridge/node-pty-prebuilt-multiarch": "~0.11.11",
"@inventage/envsubst": "^0.16.0",
"@louislam/sqlite3": "~15.1.6",
"@tuplo/envsubst": "^1.15.2",
"bcryptjs": "~2.4.3",
"check-password-strength": "~2.0.7",
"command-exists": "~1.2.9",
Expand All @@ -35,7 +35,6 @@
"croner": "~7.0.5",
"dayjs": "~1.11.10",
"dotenv": "~16.3.1",
"envsub": "~4.1.0",
"express": "~4.18.2",
"express-static-gzip": "~2.1.7",
"http-graceful-shutdown": "~3.1.13",
Expand Down
Loading

0 comments on commit 958a342

Please sign in to comment.