elestio 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +242 -4
- package/package.json +9 -2
- package/src/api.js +59 -21
- package/src/cli.js +16 -813
- package/src/commands/access.js +26 -13
- package/src/commands/auth.js +1 -1
- package/src/commands/cicd-template.js +363 -0
- package/src/commands/cicd.js +40 -15
- package/src/commands/clusters.js +337 -0
- package/src/commands/services.js +38 -26
- package/src/config.js +22 -2
- package/src/constants.js +76 -0
- package/src/deployment.js +33 -0
- package/src/payloads/pipeline.js +191 -0
- package/src/payloads/server.js +130 -0
- package/src/registry.js +663 -0
- package/src/router.js +188 -0
- package/src/templates/elestio-config.js +188 -0
- package/src/utils.js +27 -15
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { RUNTIME_PRESETS } from '../constants.js';
|
|
2
|
+
import { EMPTY_LIFECYCLE } from '../templates/elestio-config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Builders for POST /api/cicd/createCiCdExistServer.
|
|
6
|
+
*
|
|
7
|
+
* The endpoint takes the dashboard's whole form state, so most of this file is
|
|
8
|
+
* the inert scaffolding it expects. Keep it in one place: every divergence
|
|
9
|
+
* between what we send and what the dashboard sends has produced a 500.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function emptyGitUserFormData() {
|
|
13
|
+
return {
|
|
14
|
+
selectedUser: '', searchGitUser: '',
|
|
15
|
+
gitOrgsFilteredList: { GITHUB: [], GITLAB: [] },
|
|
16
|
+
gitOrgsList: [], selectedRepo: {},
|
|
17
|
+
thirdPartyRepoInput: '', gitScopesUsers: [],
|
|
18
|
+
thirdPartyRepoScopeName: '',
|
|
19
|
+
getGitScopeUser: { GITHUB: [], GITLAB: [] },
|
|
20
|
+
thirdPartyRepoName: '', thirdPartyRepoPrivate: false,
|
|
21
|
+
loadSearch: false
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function defaultPorts(targetPort = 3001) {
|
|
26
|
+
return [{
|
|
27
|
+
protocol: 'HTTPS', targetProtocol: 'HTTP', listeningPort: '443',
|
|
28
|
+
targetPort: String(targetPort), public: true, targetIP: '172.17.0.1',
|
|
29
|
+
path: '/', isAuth: false, login: '', password: '', loginTitle: ''
|
|
30
|
+
}];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function defaultExposedPorts(hostPort = '3000', containerPort = '3000') {
|
|
34
|
+
return [{ protocol: 'HTTP', hostPort: String(hostPort), containerPort: String(containerPort), interface: '172.17.0.1' }];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @param {object} opts.target CI/CD target, from resolveCicdTarget()
|
|
40
|
+
* @param {string} opts.projectId
|
|
41
|
+
* @param {string} opts.pipelineName
|
|
42
|
+
* @param {string} opts.cicdMode GITHUB | GITLAB | DockerCompose
|
|
43
|
+
* @param {object} [opts.gitData]
|
|
44
|
+
* @param {object} [opts.imageData]
|
|
45
|
+
* @param {object} [opts.configData]
|
|
46
|
+
* @param {Array} [opts.ports]
|
|
47
|
+
* @param {Array} [opts.exposedPorts]
|
|
48
|
+
* @param {string} [opts.variables] newline-separated KEY=VALUE, never an array
|
|
49
|
+
* @param {object} [opts.lifeCycleCommand]
|
|
50
|
+
* @param {Array} [opts.copyCommandConfig]
|
|
51
|
+
* @param {string|null} [opts.authID]
|
|
52
|
+
* @param {boolean} [opts.isPublicGitRepo]
|
|
53
|
+
* @param {boolean} [opts.isNeedToCreateRepo]
|
|
54
|
+
*/
|
|
55
|
+
export function buildPipelinePayload(opts) {
|
|
56
|
+
const {
|
|
57
|
+
target, projectId, pipelineName, cicdMode,
|
|
58
|
+
gitData = {}, imageData = {}, configData = {},
|
|
59
|
+
ports, exposedPorts, variables = '',
|
|
60
|
+
lifeCycleCommand = {}, copyCommandConfig = [],
|
|
61
|
+
authID = null, isPublicGitRepo = false, isNeedToCreateRepo = false
|
|
62
|
+
} = opts;
|
|
63
|
+
|
|
64
|
+
if (!target) throw new Error('A CI/CD target is required');
|
|
65
|
+
if (!projectId) throw new Error('Project ID is required');
|
|
66
|
+
if (!pipelineName) throw new Error('Pipeline name is required');
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
cluster: { isCluster: false, createNew: false, target },
|
|
70
|
+
gitData,
|
|
71
|
+
imageData,
|
|
72
|
+
configData: {
|
|
73
|
+
buildDir: configData.buildDir || '/',
|
|
74
|
+
rootDir: configData.rootDir || '/',
|
|
75
|
+
runTime: configData.runTime || 'NodeJs',
|
|
76
|
+
buildCmd: configData.buildCmd || '',
|
|
77
|
+
runCmd: configData.runCmd || '',
|
|
78
|
+
installCmd: configData.installCmd || '',
|
|
79
|
+
framework: configData.framework || 'NoFramework',
|
|
80
|
+
version: configData.version === undefined || configData.version === null ? '' : String(configData.version)
|
|
81
|
+
},
|
|
82
|
+
ports: ports && ports.length > 0 ? ports : defaultPorts(),
|
|
83
|
+
// The backend runs variables.trim(); anything but a string is a 500.
|
|
84
|
+
variables: typeof variables === 'string' ? variables : '',
|
|
85
|
+
isPublicGitRepo,
|
|
86
|
+
exposedPorts: exposedPorts && exposedPorts.length > 0 ? exposedPorts : defaultExposedPorts(),
|
|
87
|
+
gitVolumeConfig: [{}],
|
|
88
|
+
isNeedToCreateRepo,
|
|
89
|
+
gitUserFormData: emptyGitUserFormData(),
|
|
90
|
+
lifeCycleCommand: { ...EMPTY_LIFECYCLE, ...lifeCycleCommand },
|
|
91
|
+
monoRepoWorkSpaces: [''],
|
|
92
|
+
copyCommandConfig,
|
|
93
|
+
CICDMode: cicdMode,
|
|
94
|
+
projectID: String(projectId),
|
|
95
|
+
pipelineName,
|
|
96
|
+
isMovePipeline: false,
|
|
97
|
+
authID: authID === null || authID === undefined ? null : String(authID)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pipeline running a Git repo, configured from the repo's elestio.yml when it
|
|
103
|
+
* has one and from the runtime preset otherwise.
|
|
104
|
+
*/
|
|
105
|
+
export function buildGitPipelinePayload({
|
|
106
|
+
target, projectId, pipelineName, gitType, repo, branch, repoID,
|
|
107
|
+
elestioConfig, authID, isPublicGitRepo, isNeedToCreateRepo, appType = 'static', overrides = {}
|
|
108
|
+
}) {
|
|
109
|
+
const preset = RUNTIME_PRESETS[appType] || RUNTIME_PRESETS.static;
|
|
110
|
+
const gitHost = gitType === 'GITLAB' ? 'gitlab.com' : 'github.com';
|
|
111
|
+
const repoName = repo.split('/')[1];
|
|
112
|
+
const fromFile = elestioConfig && elestioConfig.hasConfig ? elestioConfig : null;
|
|
113
|
+
|
|
114
|
+
const configData = {
|
|
115
|
+
runTime: overrides.runTime || fromFile?.config.runTime || preset.runtime,
|
|
116
|
+
version: overrides.nodeVersion ?? fromFile?.config.version ?? preset.version,
|
|
117
|
+
framework: overrides.framework || fromFile?.config.framework || preset.framework,
|
|
118
|
+
buildDir: overrides.buildDir || fromFile?.config.buildDir || preset.buildDir,
|
|
119
|
+
rootDir: overrides.rootDir || fromFile?.config.rootDir || '/',
|
|
120
|
+
buildCmd: overrides.buildCmd ?? fromFile?.config.buildCmd ?? preset.buildCmd,
|
|
121
|
+
runCmd: overrides.runCmd ?? fromFile?.config.runCmd ?? preset.runCmd,
|
|
122
|
+
installCmd: overrides.installCmd ?? fromFile?.config.installCmd ?? preset.installCmd
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return buildPipelinePayload({
|
|
126
|
+
target, projectId, pipelineName,
|
|
127
|
+
cicdMode: gitType,
|
|
128
|
+
gitData: {
|
|
129
|
+
projectName: pipelineName,
|
|
130
|
+
branch,
|
|
131
|
+
repoUrl: `https://${gitHost}/${repo}`,
|
|
132
|
+
cloneUrl: `https://${gitHost}/${repo}.git`,
|
|
133
|
+
repoID: repoID === undefined || repoID === null ? '' : String(repoID),
|
|
134
|
+
repo: repoName,
|
|
135
|
+
...(isNeedToCreateRepo ? { createFromTemplate: true, gitTemplateName: repoName } : {})
|
|
136
|
+
},
|
|
137
|
+
imageData: { isPipelineTemplate: false },
|
|
138
|
+
configData,
|
|
139
|
+
ports: overrides.ports || fromFile?.ports,
|
|
140
|
+
exposedPorts: overrides.exposedPorts || fromFile?.exposedPorts,
|
|
141
|
+
variables: overrides.variables ?? fromFile?.variables ?? '',
|
|
142
|
+
lifeCycleCommand: fromFile?.lifeCycleCommand,
|
|
143
|
+
copyCommandConfig: fromFile?.copyCommandConfig || [],
|
|
144
|
+
authID,
|
|
145
|
+
isPublicGitRepo: !!isPublicGitRepo,
|
|
146
|
+
isNeedToCreateRepo: !!isNeedToCreateRepo
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Pipeline running an inline docker-compose file, with no Git repo attached.
|
|
152
|
+
*
|
|
153
|
+
* Lifecycle hooks are deliberately dropped. There is no checkout, so the paths
|
|
154
|
+
* in elestio.yml do not exist, and the deployment agent chmods them before it
|
|
155
|
+
* runs anything: sending them fails the build with
|
|
156
|
+
* "chmod: cannot access './scripts/preInstall.sh'" before docker compose is
|
|
157
|
+
* even reached. Sending none lets the stack come up.
|
|
158
|
+
*/
|
|
159
|
+
export function buildComposePipelinePayload({
|
|
160
|
+
target, projectId, pipelineName, compose, elestioConfig, overrides = {}
|
|
161
|
+
}) {
|
|
162
|
+
if (!compose) throw new Error('A docker-compose file is required for DockerCompose mode');
|
|
163
|
+
const fromFile = elestioConfig && elestioConfig.hasConfig ? elestioConfig : null;
|
|
164
|
+
|
|
165
|
+
return buildPipelinePayload({
|
|
166
|
+
target, projectId, pipelineName,
|
|
167
|
+
cicdMode: 'DockerCompose',
|
|
168
|
+
gitData: {},
|
|
169
|
+
imageData: { isPrivate: false, compose, dockerExample: '', repoName: 'CustomDocker' },
|
|
170
|
+
configData: {
|
|
171
|
+
runTime: 'NodeJs', framework: 'NoFramework', version: '20',
|
|
172
|
+
buildDir: '/', rootDir: '/', buildCmd: '', runCmd: '', installCmd: ''
|
|
173
|
+
},
|
|
174
|
+
ports: overrides.ports || fromFile?.ports,
|
|
175
|
+
exposedPorts: overrides.exposedPorts || fromFile?.exposedPorts,
|
|
176
|
+
variables: overrides.variables ?? fromFile?.variables ?? '',
|
|
177
|
+
lifeCycleCommand: {},
|
|
178
|
+
copyCommandConfig: fromFile?.copyCommandConfig || [],
|
|
179
|
+
authID: null,
|
|
180
|
+
isPublicGitRepo: false,
|
|
181
|
+
isNeedToCreateRepo: false
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const DEFAULT_COMPOSE = `services:
|
|
186
|
+
nginx:
|
|
187
|
+
image: nginx:alpine
|
|
188
|
+
ports:
|
|
189
|
+
- "172.17.0.1:3000:80"
|
|
190
|
+
volumes:
|
|
191
|
+
- ./html:/usr/share/nginx/html:ro`;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLUSTER_MAX_NODES, CLUSTER_MODES, CICD_MODES,
|
|
3
|
+
minClusterNodes, supportsClustering, supportsMultiMaster
|
|
4
|
+
} from '../constants.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Builder and validation for POST /api/servers/createServer.
|
|
8
|
+
*
|
|
9
|
+
* Cluster deployments differ from single services by three fields only
|
|
10
|
+
* (serviceType, clusterNodes, isReplica) -- but getting them wrong bills VMs
|
|
11
|
+
* before the API rejects the request, so everything is validated upfront.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} cluster
|
|
16
|
+
* @param {boolean} cluster.enabled
|
|
17
|
+
* @param {string} cluster.mode primary-replica | multi-master
|
|
18
|
+
* @param {number} cluster.nodes total nodes, primary included
|
|
19
|
+
* @param {object} template catalog template, needs at least { id, title }
|
|
20
|
+
* @returns {{nodes: number, isReplica: boolean, mode: string}}
|
|
21
|
+
*/
|
|
22
|
+
export function validateClusterOptions(cluster, template) {
|
|
23
|
+
const templateId = Number(template.id);
|
|
24
|
+
|
|
25
|
+
if (!supportsClustering(template)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`"${template.title}" does not support clustering. ` +
|
|
28
|
+
'Run "elestio clusters templates" to list the software that does.'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const mode = cluster.mode || 'primary-replica';
|
|
33
|
+
if (!CLUSTER_MODES.includes(mode)) {
|
|
34
|
+
throw new Error(`Unknown cluster mode "${mode}". Expected one of: ${CLUSTER_MODES.join(', ')}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (mode === 'multi-master' && !supportsMultiMaster(templateId)) {
|
|
38
|
+
throw new Error(`"${template.title}" does not support multi-master; use --cluster-mode primary-replica`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const min = minClusterNodes(templateId);
|
|
42
|
+
const nodes = cluster.nodes === undefined || cluster.nodes === null ? min : Number(cluster.nodes);
|
|
43
|
+
|
|
44
|
+
if (!Number.isInteger(nodes)) {
|
|
45
|
+
throw new Error(`--nodes must be a whole number, got "${cluster.nodes}"`);
|
|
46
|
+
}
|
|
47
|
+
if (nodes < min) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`"${template.title}" needs at least ${min} nodes` +
|
|
50
|
+
(min === 3 ? ' (quorum-based software requires an odd number of nodes)' : '')
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (nodes > CLUSTER_MAX_NODES) {
|
|
54
|
+
throw new Error(`A cluster cannot exceed ${CLUSTER_MAX_NODES} nodes, got ${nodes}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { nodes, isReplica: mode === 'primary-replica', mode };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} opts see deployService() for where each value comes from
|
|
62
|
+
*/
|
|
63
|
+
export function buildCreateServerPayload(opts) {
|
|
64
|
+
const {
|
|
65
|
+
template, projectId, serverName, serverType, datacenter, provider,
|
|
66
|
+
support, adminEmail, version, serviceType, cluster = null,
|
|
67
|
+
pipelineName = null
|
|
68
|
+
} = opts;
|
|
69
|
+
|
|
70
|
+
const payload = {
|
|
71
|
+
templateID: String(template.id),
|
|
72
|
+
serverType,
|
|
73
|
+
datacenter,
|
|
74
|
+
providerName: provider,
|
|
75
|
+
serverName,
|
|
76
|
+
appid: 'Cloudxx',
|
|
77
|
+
data: 'data',
|
|
78
|
+
support,
|
|
79
|
+
projectId: String(projectId),
|
|
80
|
+
version,
|
|
81
|
+
adminEmail,
|
|
82
|
+
deploymentServiceType: 'normal',
|
|
83
|
+
serviceType
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (serviceType === 'CICD') {
|
|
87
|
+
payload.cicdPayload = buildCicdPayload(pipelineName || serverName, opts.cicdMode);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (cluster) {
|
|
91
|
+
payload.serviceType = 'Cluster';
|
|
92
|
+
payload.clusterNodes = cluster.nodes;
|
|
93
|
+
payload.isReplica = cluster.isReplica;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return payload;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* createServer rejects a CI/CD target whose cicdPayload is incomplete. It needs
|
|
101
|
+
* both a pipelineName and a CICDMode, and the name has its own rules that the
|
|
102
|
+
* API only reports after the request is on the wire.
|
|
103
|
+
*/
|
|
104
|
+
export function buildCicdPayload(pipelineName, cicdMode = 'DockerCompose') {
|
|
105
|
+
const name = String(pipelineName).replace(/\s/g, '').replaceAll('_', '-');
|
|
106
|
+
|
|
107
|
+
if (!/^[a-z0-9-]+$/.test(name) || name.length > 24) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Invalid pipeline name "${pipelineName}". Use at most 24 characters, ` +
|
|
110
|
+
'lowercase letters, digits and hyphens only.'
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!CICD_MODES.includes(cicdMode)) {
|
|
115
|
+
throw new Error(`Unknown CI/CD mode "${cicdMode}". Expected one of: ${CICD_MODES.join(', ')}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { pipelineName: name, CICDMode: cicdMode };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Human-readable summary of what a cluster deployment will actually create,
|
|
123
|
+
* used by both --dry-run and the confirmation line.
|
|
124
|
+
*/
|
|
125
|
+
export function describeCluster(cluster) {
|
|
126
|
+
if (!cluster) return '1 node';
|
|
127
|
+
return cluster.isReplica
|
|
128
|
+
? `1 primary + ${cluster.nodes - 1} replica${cluster.nodes - 1 === 1 ? '' : 's'} (${cluster.nodes} VMs)`
|
|
129
|
+
: `${cluster.nodes} primaries, multi-master (${cluster.nodes} VMs)`;
|
|
130
|
+
}
|