Skip to content

Commit 0046d31

Browse files
authored
Merge pull request #383 from ggwadera/envvar
Manage environment variables
2 parents f2b4271 + ec88a3f commit 0046d31

File tree

9 files changed

+334
-17
lines changed

9 files changed

+334
-17
lines changed

src/api/mapping.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
getMappings,
99
getMappingByDomain,
1010
getMappingById,
11-
deleteDomain
11+
deleteDomain,
12+
updateDomain
1213
} from '../lib/data'
1314
import { Mapping } from '../types/general'
1415
import { getGitUserId, getGitGroupId } from '../helpers/getGitUser'
@@ -18,8 +19,10 @@ import {
1819
createContainer,
1920
startContainer,
2021
stopContainer,
21-
removeContainer
22+
removeContainer,
23+
inspectContainer
2224
} from '../helpers/docker'
25+
import { DockerError } from '../types/docker'
2326
const mappingRouter = express.Router()
2427
const exec = util.promisify(cp.exec)
2528
const getNextPort = (map, start = 3002): number => {
@@ -153,14 +156,62 @@ mappingRouter.get('/:id/start', (req, res) => {
153156
const { id } = req.params
154157
startContainer(id)
155158
.then(() => res.sendStatus(204))
156-
.catch(err => res.status(err.statusCode).json(err.json))
159+
.catch((err: DockerError) => res.status(err.statusCode).json(err.json))
157160
})
158161

159162
mappingRouter.get('/:id/stop', (req, res) => {
160163
const { id } = req.params
161164
stopContainer(id)
162165
.then(() => res.sendStatus(204))
163-
.catch(err => res.status(err.statusCode).json(err.json))
166+
.catch((err: DockerError) => res.status(err.statusCode).json(err.json))
167+
})
168+
169+
mappingRouter.get('/:fullDomain/environment', (req, res) => {
170+
const { fullDomain } = req.params
171+
inspectContainer(fullDomain)
172+
.then(info => {
173+
const envVars = info.Config.Env
174+
const defaultEnvs = new Set([
175+
'NODE_ENV',
176+
'PORT',
177+
'PATH',
178+
'NODE_VERSION',
179+
'YARN_VERSION'
180+
])
181+
const nonDefaultEnvs = envVars.reduce((acc, envVar) => {
182+
const [name, value] = envVar.split('=')
183+
if (!defaultEnvs.has(name)) {
184+
acc[name] = value
185+
}
186+
return acc
187+
}, {})
188+
res.json({ variables: nonDefaultEnvs })
189+
})
190+
.catch((err: DockerError) => res.status(err.statusCode).json(err.json))
191+
})
192+
193+
mappingRouter.put('/:fullDomain/environment', (req, res) => {
194+
const { fullDomain } = req.params
195+
const { variables } = req.body
196+
if (Object.entries(variables).some(([name, value]) => !name || !value)) {
197+
return res.status(400).json({ message: 'Fields must not be blank.' })
198+
}
199+
// convert to the format Docker expects: NAME=VALUE
200+
const envVars = Object.entries(variables).map(
201+
([name, value]) => `${name}=${value}`
202+
)
203+
const mapping = getMappingByDomain(fullDomain)
204+
removeContainer(fullDomain)
205+
.then(() => createContainer(fullDomain, Number(mapping.port), envVars))
206+
.then(id => {
207+
// since docker generates a new ID for the container
208+
// the domain record needs to be updated
209+
mapping.id = id
210+
updateDomain(fullDomain, mapping)
211+
return startContainer(id)
212+
})
213+
.then(() => res.sendStatus(201))
214+
.catch((err: DockerError) => res.status(err.statusCode).json(err.json))
164215
})
165216

166217
export default mappingRouter

src/helpers/docker.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ const getContainerLogs = async (
2929

3030
const createContainer = async (
3131
fullDomain: string,
32-
port: number
32+
port: number,
33+
environmentVariables: string[] = []
3334
): Promise<string> => {
3435
const workPath = path.resolve(environment.WORKPATH, fullDomain)
3536
return docker
@@ -42,7 +43,7 @@ const createContainer = async (
4243
},
4344
Tty: false,
4445
WorkingDir: '/home/node/app',
45-
Env: ['NODE_ENV=production', 'PORT=3000'],
46+
Env: ['NODE_ENV=production', 'PORT=3000', ...environmentVariables],
4647
HostConfig: {
4748
Binds: [`${workPath}:/home/node/app`],
4849
RestartPolicy: {
@@ -86,11 +87,17 @@ const removeContainer = async (id: string): Promise<unknown> => {
8687
return container.remove({ v: true, force: true })
8788
}
8889

90+
const inspectContainer = (id: string): Promise<Docker.ContainerInspectInfo> => {
91+
const container = docker.getContainer(id)
92+
return container.inspect()
93+
}
94+
8995
export {
9096
getContainersList,
9197
getContainerLogs,
9298
createContainer,
9399
startContainer,
94100
stopContainer,
95-
removeContainer
101+
removeContainer,
102+
inspectContainer
96103
}

src/lib/data.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ const deleteDomain = (domain: string): void => {
102102
setData('mappings', Object.values(domainToMapping))
103103
}
104104

105+
const updateDomain = (domain: string, updatedMapping: Mapping): void => {
106+
domainToMapping[domain] = updatedMapping
107+
setData('mappings', Object.values(domainToMapping))
108+
}
109+
105110
export {
106111
getData,
107112
setData,
@@ -112,5 +117,6 @@ export {
112117
getMappingByDomain,
113118
getMappingById,
114119
getTokenById,
115-
deleteDomain
120+
deleteDomain,
121+
updateDomain
116122
}

src/public/client.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,27 +95,34 @@ class MappingItem {
9595
${data.gitLink}
9696
</small>
9797
</div>
98+
<a
99+
href="/manage/${data.fullDomain}"
100+
class="btn btn-sm btn-outline-primary mr-3 manage-button"
101+
role="button"
102+
>
103+
Manage
104+
</a>
98105
<button
99-
class="btn btn-sm btn-outline-success mr-3 startButton"
106+
class="btn btn-sm btn-outline-success mr-3 start-button"
100107
type="button"
101108
>
102109
Start/Restart
103110
</button>
104111
<button
105-
class="btn btn-sm btn-outline-warning mr-3 stopButton"
112+
class="btn btn-sm btn-outline-warning mr-3 stop-button"
106113
type="button"
107114
>
108115
Stop
109116
</button>
110117
<button
111-
class="btn btn-sm btn-outline-danger mr-3 deleteButton"
118+
class="btn btn-sm btn-outline-danger mr-3 delete-button"
112119
type="button"
113120
>
114121
Delete
115122
</button>
116123
`
117124

118-
const startButton = helper.getElement('.startButton', mappingElement)
125+
const startButton = helper.getElement('.start-button', mappingElement)
119126
startButton.onclick = (): void => {
120127
if (confirm('Are you sure want to start/restart this domain?')) {
121128
fetch(`/api/mappings/${data.id}/start`)
@@ -127,7 +134,7 @@ class MappingItem {
127134
)
128135
}
129136
}
130-
const stopButton = helper.getElement('.stopButton', mappingElement)
137+
const stopButton = helper.getElement('.stop-button', mappingElement)
131138
stopButton.onclick = (): void => {
132139
if (confirm('Are you sure want to stop this domain?')) {
133140
fetch(`/api/mappings/${data.id}/stop`)
@@ -139,7 +146,7 @@ class MappingItem {
139146
)
140147
}
141148
}
142-
const delButton = helper.getElement('.deleteButton', mappingElement)
149+
const delButton = helper.getElement('.delete-button', mappingElement)
143150
delButton.onclick = (): void => {
144151
if (confirm('Are you sure want to delete this domain?')) {
145152
fetch(`/api/mappings/${data.id}`, {

src/public/manageDomain.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
const envList = helper.getElement('.envList')
2+
const environmentVariables: EnvironmentItem[] = []
3+
const fullDomain = window.location.pathname.split('/').pop()
4+
document.getElementById('full-domain').innerText = fullDomain
5+
6+
class EnvironmentItem {
7+
name: string
8+
value: string
9+
isValid: boolean
10+
11+
private listItemElement: HTMLLIElement
12+
private nameInputElement: HTMLInputElement
13+
private valueInputElement: HTMLInputElement
14+
private removeButtonElement: HTMLButtonElement
15+
16+
private readonly IS_INVALID = 'is-invalid'
17+
private readonly LETTER_NUMBER_REGEX = /^[A-Z0-9_]+$/g
18+
private readonly ELEMENT_HTML = `
19+
<div class="input-group">
20+
<input
21+
type="text"
22+
aria-label="Name"
23+
class="form-control text-uppercase text-monospace name-input"
24+
placeholder="NAME"
25+
>
26+
<div class="input-group-prepend input-group-append">
27+
<span class="input-group-text">=</span>
28+
</div>
29+
<input
30+
type="text"
31+
aria-label="Value"
32+
class="form-control text-monospace value-input"
33+
placeholder="Value"
34+
>
35+
<div class="input-group-append">
36+
<button
37+
class="btn btn-outline-danger remove-button"
38+
type="button"
39+
>
40+
Remove
41+
</button>
42+
</div>
43+
<div class="invalid-feedback">
44+
Name must contain only letters, number, or underscore.
45+
</div>
46+
</div>
47+
`
48+
49+
constructor(name?: string, value?: string) {
50+
this.name = name
51+
this.value = value
52+
this.createElement()
53+
this.setupEventListeners()
54+
if (name && value) {
55+
this.nameInputElement.value = this.name
56+
this.valueInputElement.value = this.value
57+
this.setValid(true)
58+
} else {
59+
this.setValid(false)
60+
}
61+
this.nameInputElement.focus()
62+
}
63+
64+
/**
65+
* Sets the valid status for the object and updates the input DOM element.
66+
* @param isValid
67+
*/
68+
private setValid(isValid: boolean): void {
69+
if (isValid === this.isValid) return
70+
this.isValid = isValid
71+
if (isValid) this.nameInputElement.classList.remove(this.IS_INVALID)
72+
else this.nameInputElement.classList.add(this.IS_INVALID)
73+
}
74+
75+
/**
76+
* Create the list element and append to the DOM
77+
*/
78+
private createElement(): void {
79+
this.listItemElement = document.createElement('li')
80+
this.listItemElement.innerHTML = this.ELEMENT_HTML
81+
this.listItemElement.classList.add(
82+
'list-group-item',
83+
'd-flex',
84+
'align-items-center'
85+
)
86+
envList.appendChild(this.listItemElement)
87+
this.nameInputElement = this.listItemElement.querySelector('.name-input')
88+
this.valueInputElement = this.listItemElement.querySelector('.value-input')
89+
this.removeButtonElement = this.listItemElement.querySelector(
90+
'.remove-button'
91+
)
92+
}
93+
94+
/**
95+
* Setup the event listeners for inputs and button
96+
*/
97+
private setupEventListeners(): void {
98+
this.nameInputElement.addEventListener('input', () =>
99+
this.validateAndSetName(this.nameInputElement.value)
100+
)
101+
this.valueInputElement.addEventListener(
102+
'input',
103+
() => (this.value = this.valueInputElement.value)
104+
)
105+
this.removeButtonElement.addEventListener('click', () =>
106+
this.removeElement()
107+
)
108+
}
109+
110+
/**
111+
* Validates the variable name with regex and sets the field if it's valid
112+
* @param value input value
113+
*/
114+
private validateAndSetName(value: string): void {
115+
const upperCaseValue = value.toUpperCase()
116+
if (upperCaseValue.match(this.LETTER_NUMBER_REGEX)) {
117+
this.name = upperCaseValue
118+
this.setValid(true)
119+
} else {
120+
this.setValid(false)
121+
}
122+
}
123+
124+
/**
125+
* Removes the list item element from the DOM and the items list.
126+
* @param element list item element
127+
*/
128+
private removeElement(): void {
129+
this.listItemElement.remove()
130+
environmentVariables.splice(environmentVariables.indexOf(this), 1)
131+
}
132+
}
133+
134+
document.getElementById('addEnvButton').onclick = (): void => {
135+
environmentVariables.push(new EnvironmentItem())
136+
}
137+
138+
document.getElementById('submitEnvButton').onclick = (): void => {
139+
// check if there's at least one variable to submit
140+
if (environmentVariables.length === 0) {
141+
return alert('Error: add at least one variable before submitting.')
142+
}
143+
// check if all variables are valid
144+
if (environmentVariables.some(env => !env.isValid)) {
145+
return alert(
146+
'Error: some fields are not valid, please fix them before submitting.'
147+
)
148+
}
149+
// confirm if it's OK to restart the app and send the request
150+
if (
151+
confirm(
152+
'Submitting will recreate the container for your app.' +
153+
'All data not saved inside the main app folder will be lost. Is this OK?'
154+
)
155+
) {
156+
// convert to the request body format
157+
// { variables: { NAME: value, ... }}
158+
const body = environmentVariables.reduce((acc, { name, value }) => {
159+
acc[name] = value
160+
return acc
161+
}, {})
162+
// send the PUT request
163+
// show alert with error if request is not successful
164+
fetch(`/api/mappings/${fullDomain}/environment`, {
165+
method: 'PUT',
166+
headers: {
167+
'Content-Type': 'application/json'
168+
},
169+
body: JSON.stringify({ variables: body })
170+
})
171+
.then(response => (response.ok ? {} : response.json()))
172+
.then((body: ContainerResponse) =>
173+
body.message
174+
? alert(`ERROR: ${body.message}`)
175+
: window.location.reload()
176+
)
177+
}
178+
}
179+
180+
// Populate the list with existing environment variables
181+
fetch(`/api/mappings/${fullDomain}/environment`)
182+
.then(r => r.json())
183+
.then(({ variables }) => {
184+
Object.entries(variables).forEach(([name, value]) => {
185+
environmentVariables.push(
186+
new EnvironmentItem(name as string, value as string)
187+
)
188+
})
189+
})

0 commit comments

Comments
 (0)