@akash-chowdhury-24/deployhub 2.0.3 → 2.0.5
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 +50 -4
- package/package.json +1 -1
- package/src/artifact/engine.js +13 -0
- package/src/commands/doctor.js +154 -0
- package/src/commands/init.js +28 -2
- package/src/core/stages.js +13 -0
- package/src/deployment/deployment-env.js +9 -0
- package/src/deployment/providers/kubernetes.js +6 -4
- package/src/deployment/providers/ssh.js +75 -10
- package/src/utils/dockerfile.js +486 -0
- package/src/utils/github-actions.js +56 -1
- package/src/utils/kubernetes-manifests.js +168 -0
- package/src/utils/nginx.js +60 -3
- package/src/utils/scaffold.js +192 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} name
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
export function sanitizeK8sName(name) {
|
|
9
|
+
const sanitized = String(name)
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
12
|
+
.replace(/^-+|-+$/g, '')
|
|
13
|
+
.slice(0, 63);
|
|
14
|
+
return sanitized || 'app';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} cwd
|
|
19
|
+
* @returns {Promise<boolean>}
|
|
20
|
+
*/
|
|
21
|
+
export async function hasKubernetesManifests(cwd) {
|
|
22
|
+
if (await fs.pathExists(path.join(cwd, 'k8s'))) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let files = [];
|
|
27
|
+
try {
|
|
28
|
+
files = await fs.readdir(cwd);
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
if (!/\.ya?ml$/i.test(file)) continue;
|
|
35
|
+
const content = await fs.readFile(path.join(cwd, file), 'utf-8');
|
|
36
|
+
if (/^\s*apiVersion:/m.test(content) && /^\s*kind:/m.test(content)) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} options
|
|
46
|
+
* @param {string} options.appName
|
|
47
|
+
* @param {string} options.imageName
|
|
48
|
+
* @param {string} [options.imageTag]
|
|
49
|
+
* @param {number} options.port
|
|
50
|
+
* @param {string} options.namespace
|
|
51
|
+
* @param {string} [options.imagePullSecret]
|
|
52
|
+
* @returns {{ deploymentYaml: string, serviceYaml: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function generateKubernetesManifests({
|
|
55
|
+
appName,
|
|
56
|
+
imageName,
|
|
57
|
+
imageTag = 'latest',
|
|
58
|
+
port,
|
|
59
|
+
namespace,
|
|
60
|
+
imagePullSecret = '',
|
|
61
|
+
}) {
|
|
62
|
+
const name = sanitizeK8sName(appName);
|
|
63
|
+
const image = imageName.includes(':') ? imageName : `${imageName}:${imageTag}`;
|
|
64
|
+
const pullSecretBlock = imagePullSecret
|
|
65
|
+
? ` imagePullSecrets:\n - name: ${imagePullSecret}\n`
|
|
66
|
+
: '';
|
|
67
|
+
|
|
68
|
+
const deploymentYaml = `apiVersion: apps/v1
|
|
69
|
+
kind: Deployment
|
|
70
|
+
metadata:
|
|
71
|
+
name: ${name}
|
|
72
|
+
namespace: ${namespace}
|
|
73
|
+
spec:
|
|
74
|
+
# Adjust replica count as needed
|
|
75
|
+
replicas: 1
|
|
76
|
+
selector:
|
|
77
|
+
matchLabels:
|
|
78
|
+
app: ${name}
|
|
79
|
+
template:
|
|
80
|
+
metadata:
|
|
81
|
+
labels:
|
|
82
|
+
app: ${name}
|
|
83
|
+
spec:
|
|
84
|
+
${pullSecretBlock} containers:
|
|
85
|
+
- name: ${name}
|
|
86
|
+
image: ${image}
|
|
87
|
+
ports:
|
|
88
|
+
- containerPort: ${port}
|
|
89
|
+
resources:
|
|
90
|
+
requests:
|
|
91
|
+
memory: "128Mi"
|
|
92
|
+
cpu: "100m"
|
|
93
|
+
limits:
|
|
94
|
+
memory: "512Mi"
|
|
95
|
+
cpu: "500m"
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
const servicePort = port === 80 ? 80 : 80;
|
|
99
|
+
const targetPort = port;
|
|
100
|
+
|
|
101
|
+
const serviceYaml = `apiVersion: v1
|
|
102
|
+
kind: Service
|
|
103
|
+
metadata:
|
|
104
|
+
name: ${name}
|
|
105
|
+
namespace: ${namespace}
|
|
106
|
+
spec:
|
|
107
|
+
type: ClusterIP
|
|
108
|
+
selector:
|
|
109
|
+
app: ${name}
|
|
110
|
+
ports:
|
|
111
|
+
- port: ${servicePort}
|
|
112
|
+
targetPort: ${targetPort}
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
return { deploymentYaml, serviceYaml };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
120
|
+
* @param {Record<string, Record<string, unknown>>} [environments]
|
|
121
|
+
* @returns {{ appName: string, imageName: string, imageTag: string, port: number, namespace: string, imagePullSecret: string }}
|
|
122
|
+
*/
|
|
123
|
+
export function resolveKubernetesManifestOptions(config, environments = {}) {
|
|
124
|
+
const envList = Object.values(environments);
|
|
125
|
+
const k8sEnv = envList.find((env) => env.type === 'kubernetes') || {};
|
|
126
|
+
|
|
127
|
+
const appName = config.project || 'app';
|
|
128
|
+
const imageName =
|
|
129
|
+
/** @type {string} */ (k8sEnv.dockerImageName) ||
|
|
130
|
+
process.env.DOCKER_IMAGE_NAME ||
|
|
131
|
+
appName;
|
|
132
|
+
const imageTag =
|
|
133
|
+
process.env.DOCKER_IMAGE_TAG || config.version || 'latest';
|
|
134
|
+
const namespace =
|
|
135
|
+
/** @type {string} */ (k8sEnv.kubeNamespace) ||
|
|
136
|
+
process.env.KUBE_NAMESPACE ||
|
|
137
|
+
appName ||
|
|
138
|
+
'default';
|
|
139
|
+
const imagePullSecret =
|
|
140
|
+
/** @type {string} */ (k8sEnv.kubeImagePullSecret) ||
|
|
141
|
+
process.env.KUBE_IMAGE_PULL_SECRET ||
|
|
142
|
+
'';
|
|
143
|
+
|
|
144
|
+
let port = 3000;
|
|
145
|
+
if (config.projectType === 'both' && config.backend?.port) {
|
|
146
|
+
port = config.backend.port;
|
|
147
|
+
} else if (config.port) {
|
|
148
|
+
port = config.port;
|
|
149
|
+
} else if (config.backend?.port) {
|
|
150
|
+
port = config.backend.port;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
appName,
|
|
155
|
+
imageName,
|
|
156
|
+
imageTag,
|
|
157
|
+
port,
|
|
158
|
+
namespace,
|
|
159
|
+
imagePullSecret,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default {
|
|
164
|
+
sanitizeK8sName,
|
|
165
|
+
hasKubernetesManifests,
|
|
166
|
+
generateKubernetesManifests,
|
|
167
|
+
resolveKubernetesManifestOptions,
|
|
168
|
+
};
|
package/src/utils/nginx.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize a project name for use in Nginx config file paths.
|
|
3
|
+
* @param {string} projectName
|
|
4
|
+
* @returns {string}
|
|
5
|
+
*/
|
|
6
|
+
export function sanitizeNginxProjectName(projectName) {
|
|
7
|
+
return projectName.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
8
|
+
}
|
|
9
|
+
|
|
1
10
|
/**
|
|
2
11
|
* Generate nginx server block config for SPA frontend deployments.
|
|
3
12
|
*
|
|
@@ -22,13 +31,61 @@ export function generateNginxConfig(projectName, deployPath, buildOutput = 'dist
|
|
|
22
31
|
`;
|
|
23
32
|
}
|
|
24
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Debian/Ubuntu: sites-available path for this project.
|
|
36
|
+
* @param {string} projectName
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function getNginxSitesAvailablePath(projectName) {
|
|
40
|
+
return `/etc/nginx/sites-available/${sanitizeNginxProjectName(projectName)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Debian/Ubuntu: sites-enabled symlink path for this project.
|
|
45
|
+
* @param {string} projectName
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function getNginxSitesEnabledPath(projectName) {
|
|
49
|
+
return `/etc/nginx/sites-enabled/${sanitizeNginxProjectName(projectName)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* RHEL/Amazon Linux: conf.d drop-in path for this project.
|
|
54
|
+
* @param {string} projectName
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function getNginxConfDPath(projectName) {
|
|
58
|
+
return `/etc/nginx/conf.d/${sanitizeNginxProjectName(projectName)}.conf`;
|
|
59
|
+
}
|
|
60
|
+
|
|
25
61
|
/**
|
|
26
62
|
* @param {string} projectName
|
|
27
63
|
* @returns {string}
|
|
28
64
|
*/
|
|
29
65
|
export function getNginxSitePath(projectName) {
|
|
30
|
-
|
|
31
|
-
|
|
66
|
+
return getNginxSitesAvailablePath(projectName);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} sshUser
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function formatPasswordlessSudoGuidance(sshUser) {
|
|
74
|
+
return (
|
|
75
|
+
`Passwordless sudo required to activate Nginx config during deploy.\n` +
|
|
76
|
+
` On your server, run sudo visudo and add a line like:\n` +
|
|
77
|
+
` ${sshUser} ALL=(ALL) NOPASSWD: /usr/sbin/nginx, /bin/cp, /usr/bin/cp, /bin/systemctl, /usr/bin/systemctl\n` +
|
|
78
|
+
` (replace ${sshUser} with your SSH_USER)\n` +
|
|
79
|
+
` Security: this grants broad cp/systemctl access — see README one-time server setup for a production-hardening note.`
|
|
80
|
+
);
|
|
32
81
|
}
|
|
33
82
|
|
|
34
|
-
export default {
|
|
83
|
+
export default {
|
|
84
|
+
generateNginxConfig,
|
|
85
|
+
sanitizeNginxProjectName,
|
|
86
|
+
getNginxSitesAvailablePath,
|
|
87
|
+
getNginxSitesEnabledPath,
|
|
88
|
+
getNginxConfDPath,
|
|
89
|
+
getNginxSitePath,
|
|
90
|
+
formatPasswordlessSudoGuidance,
|
|
91
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import {
|
|
5
|
+
generateDockerfile,
|
|
6
|
+
getDockerfileFrameworkLabel,
|
|
7
|
+
resolveDockerSettings,
|
|
8
|
+
} from './dockerfile.js';
|
|
9
|
+
import {
|
|
10
|
+
generateKubernetesManifests,
|
|
11
|
+
hasKubernetesManifests,
|
|
12
|
+
resolveKubernetesManifestOptions,
|
|
13
|
+
} from './kubernetes-manifests.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Record<string, Record<string, unknown>>} environments
|
|
17
|
+
* @returns {Set<string>}
|
|
18
|
+
*/
|
|
19
|
+
export function getDeployTypes(environments) {
|
|
20
|
+
return new Set(
|
|
21
|
+
Object.values(environments)
|
|
22
|
+
.map((env) => /** @type {string} */ (env.type))
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
29
|
+
* @param {Record<string, Record<string, unknown>>} [environments]
|
|
30
|
+
*/
|
|
31
|
+
export function needsDockerfile(config, environments = config.environments || {}) {
|
|
32
|
+
const deployTypes = getDeployTypes(environments);
|
|
33
|
+
return deployTypes.has('docker') || deployTypes.has('kubernetes');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
38
|
+
* @param {Record<string, Record<string, unknown>>} [environments]
|
|
39
|
+
*/
|
|
40
|
+
export function needsKubernetesManifests(
|
|
41
|
+
config,
|
|
42
|
+
environments = config.environments || {}
|
|
43
|
+
) {
|
|
44
|
+
return getDeployTypes(environments).has('kubernetes');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} cwd
|
|
49
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
50
|
+
* @param {{ silent?: boolean }} [options]
|
|
51
|
+
* @returns {Promise<{ generated: boolean, framework?: string }>}
|
|
52
|
+
*/
|
|
53
|
+
export async function ensureDockerfile(cwd, config, options = {}) {
|
|
54
|
+
const dockerfilePath = path.join(cwd, 'Dockerfile');
|
|
55
|
+
if (await fs.pathExists(dockerfilePath)) {
|
|
56
|
+
return { generated: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!needsDockerfile(config)) {
|
|
60
|
+
return { generated: false };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const settings = resolveDockerSettings(config);
|
|
64
|
+
const content = generateDockerfile(config);
|
|
65
|
+
await fs.writeFile(dockerfilePath, content);
|
|
66
|
+
|
|
67
|
+
if (!options.silent) {
|
|
68
|
+
const label = getDockerfileFrameworkLabel(settings.framework);
|
|
69
|
+
console.log(
|
|
70
|
+
chalk.yellow(
|
|
71
|
+
`No Dockerfile found — generated a starter Dockerfile at ./Dockerfile based on your detected ${label}. Review it before deploying, especially the exposed port and start command.`
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { generated: true, framework: settings.framework };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} cwd
|
|
81
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
82
|
+
* @param {Record<string, Record<string, unknown>>} [environments]
|
|
83
|
+
* @param {{ silent?: boolean }} [options]
|
|
84
|
+
* @returns {Promise<{ generated: boolean }>}
|
|
85
|
+
*/
|
|
86
|
+
export async function ensureKubernetesManifests(
|
|
87
|
+
cwd,
|
|
88
|
+
config,
|
|
89
|
+
environments = config.environments || {},
|
|
90
|
+
options = {}
|
|
91
|
+
) {
|
|
92
|
+
if (!needsKubernetesManifests(config, environments)) {
|
|
93
|
+
return { generated: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (await hasKubernetesManifests(cwd)) {
|
|
97
|
+
return { generated: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const manifestOptions = resolveKubernetesManifestOptions(config, environments);
|
|
101
|
+
const { deploymentYaml, serviceYaml } = generateKubernetesManifests(manifestOptions);
|
|
102
|
+
|
|
103
|
+
const k8sDir = path.join(cwd, 'k8s');
|
|
104
|
+
await fs.ensureDir(k8sDir);
|
|
105
|
+
await fs.writeFile(path.join(k8sDir, 'deployment.yaml'), deploymentYaml);
|
|
106
|
+
await fs.writeFile(path.join(k8sDir, 'service.yaml'), serviceYaml);
|
|
107
|
+
|
|
108
|
+
if (!options.silent) {
|
|
109
|
+
console.log(
|
|
110
|
+
chalk.yellow(
|
|
111
|
+
'No Kubernetes manifests found — generated starter manifests at ./k8s/deployment.yaml and ./k8s/service.yaml. Review resource limits, replica count, and any environment-specific settings before deploying.'
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { generated: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {string} cwd
|
|
121
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
122
|
+
* @param {Record<string, Record<string, unknown>>} [environments]
|
|
123
|
+
* @param {{ silent?: boolean }} [options]
|
|
124
|
+
* @returns {Promise<{ dockerfile: boolean, kubernetes: boolean }>}
|
|
125
|
+
*/
|
|
126
|
+
export async function ensureDeployScaffold(
|
|
127
|
+
cwd,
|
|
128
|
+
config,
|
|
129
|
+
environments = config.environments || {},
|
|
130
|
+
options = {}
|
|
131
|
+
) {
|
|
132
|
+
const dockerResult = await ensureDockerfile(cwd, config, options);
|
|
133
|
+
const k8sResult = await ensureKubernetesManifests(cwd, config, environments, options);
|
|
134
|
+
return {
|
|
135
|
+
dockerfile: dockerResult.generated,
|
|
136
|
+
kubernetes: k8sResult.generated,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {string} srcDir
|
|
142
|
+
* @param {string} destDir
|
|
143
|
+
*/
|
|
144
|
+
export async function copyKubernetesManifestsIfPresent(srcDir, destDir) {
|
|
145
|
+
const k8sSrc = path.join(srcDir, 'k8s');
|
|
146
|
+
if (await fs.pathExists(k8sSrc)) {
|
|
147
|
+
await fs.copy(k8sSrc, path.join(destDir, 'k8s'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let files = [];
|
|
152
|
+
try {
|
|
153
|
+
files = await fs.readdir(srcDir);
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
if (!/\.ya?ml$/i.test(file)) continue;
|
|
160
|
+
const srcFile = path.join(srcDir, file);
|
|
161
|
+
const content = await fs.readFile(srcFile, 'utf-8');
|
|
162
|
+
if (/^\s*apiVersion:/m.test(content) && /^\s*kind:/m.test(content)) {
|
|
163
|
+
await fs.copy(srcFile, path.join(destDir, file));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Copy deploy-time assets from staging into artifactDir (alongside artifact.zip).
|
|
170
|
+
* @param {string} stagingDir
|
|
171
|
+
* @param {string} artifactDir
|
|
172
|
+
*/
|
|
173
|
+
export async function copyDeployAssetsToArtifactDir(stagingDir, artifactDir) {
|
|
174
|
+
for (const file of ['Dockerfile', 'docker-compose.yml']) {
|
|
175
|
+
const src = path.join(stagingDir, file);
|
|
176
|
+
if (await fs.pathExists(src)) {
|
|
177
|
+
await fs.copy(src, path.join(artifactDir, file));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await copyKubernetesManifestsIfPresent(stagingDir, artifactDir);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export default {
|
|
185
|
+
ensureDockerfile,
|
|
186
|
+
ensureKubernetesManifests,
|
|
187
|
+
ensureDeployScaffold,
|
|
188
|
+
needsDockerfile,
|
|
189
|
+
needsKubernetesManifests,
|
|
190
|
+
copyKubernetesManifestsIfPresent,
|
|
191
|
+
copyDeployAssetsToArtifactDir,
|
|
192
|
+
};
|