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.
@@ -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
+ }