k3s-deployer 0.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,104 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+ const crypto = require('node:crypto');
4
+
5
+ function slugify(value) {
6
+ return String(value || '')
7
+ .trim()
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9]+/g, '-')
10
+ .replace(/^-+|-+$/g, '')
11
+ .replace(/--+/g, '-');
12
+ }
13
+
14
+ function toDatabaseName(value) {
15
+ return String(value || '')
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, '')
18
+ .slice(0, 32) || 'appdb';
19
+ }
20
+
21
+ function hashText(value) {
22
+ return crypto.createHash('sha1').update(String(value || '')).digest('hex');
23
+ }
24
+
25
+ async function exists(filePath) {
26
+ try {
27
+ await fs.access(filePath);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ async function readIfExists(filePath) {
35
+ if (!(await exists(filePath))) {
36
+ return null;
37
+ }
38
+ return fs.readFile(filePath, 'utf8');
39
+ }
40
+
41
+ async function readJsonIfExists(filePath) {
42
+ const text = await readIfExists(filePath);
43
+ if (!text) {
44
+ return null;
45
+ }
46
+ return JSON.parse(text);
47
+ }
48
+
49
+ async function listImmediateDirectories(rootPath) {
50
+ const entries = await fs.readdir(rootPath, { withFileTypes: true });
51
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
52
+ }
53
+
54
+ async function ensureDir(dirPath) {
55
+ await fs.mkdir(dirPath, { recursive: true });
56
+ }
57
+
58
+ function shellQuote(value) {
59
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
60
+ }
61
+
62
+ function posixRelative(rootPath, targetPath) {
63
+ const relative = path.relative(rootPath, targetPath) || '.';
64
+ return relative.split(path.sep).join(path.posix.sep);
65
+ }
66
+
67
+ function uniqueBy(items, keyFn) {
68
+ const map = new Map();
69
+ for (const item of items) {
70
+ map.set(keyFn(item), item);
71
+ }
72
+ return Array.from(map.values());
73
+ }
74
+
75
+ function filterPublicEnv(envMap) {
76
+ return Object.fromEntries(
77
+ Object.entries(envMap || {}).filter(([key]) =>
78
+ key.startsWith('VITE_') || key.startsWith('NEXT_PUBLIC_') || key.startsWith('REACT_APP_'),
79
+ ),
80
+ );
81
+ }
82
+
83
+ function deriveHostAddress(target) {
84
+ if (!target || target.kind === 'local') {
85
+ return target && target.hostAddress ? target.hostAddress : '127.0.0.1';
86
+ }
87
+ return target.hostAddress || target.host;
88
+ }
89
+
90
+ module.exports = {
91
+ deriveHostAddress,
92
+ ensureDir,
93
+ exists,
94
+ filterPublicEnv,
95
+ hashText,
96
+ listImmediateDirectories,
97
+ posixRelative,
98
+ readIfExists,
99
+ readJsonIfExists,
100
+ shellQuote,
101
+ slugify,
102
+ toDatabaseName,
103
+ uniqueBy,
104
+ };
@@ -0,0 +1,114 @@
1
+ function joinCommand(command) {
2
+ return Array.isArray(command) ? command.join(' ') : String(command || '');
3
+ }
4
+
5
+ function renderNodeDockerfile(unit) {
6
+ const startCommand = joinCommand(unit.build.start || ['npm', 'start']);
7
+ const buildCommand = unit.build.build ? `RUN ${joinCommand(unit.build.build)}` : '';
8
+ return `FROM node:22-alpine
9
+ WORKDIR /app
10
+ COPY package*.json ./
11
+ COPY yarn.lock* pnpm-lock.yaml* ./
12
+ RUN npm install -g pnpm yarn >/dev/null 2>&1 || true
13
+ RUN if [ -f yarn.lock ]; then yarn install --frozen-lockfile; elif [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; else npm install; fi
14
+ COPY . .
15
+ ${buildCommand}
16
+ EXPOSE ${unit.port || 3000}
17
+ CMD ${JSON.stringify(['sh', '-lc', startCommand])}
18
+ `;
19
+ }
20
+
21
+ function renderPythonDockerfile(unit) {
22
+ const installCommand = joinCommand(unit.build.install);
23
+ const startCommand = joinCommand(unit.build.start);
24
+ return `FROM python:3.12-slim
25
+ WORKDIR /app
26
+ COPY requirements.txt ./
27
+ RUN ${installCommand}
28
+ COPY . .
29
+ EXPOSE ${unit.port || 8000}
30
+ CMD ${JSON.stringify(['sh', '-lc', startCommand])}
31
+ `;
32
+ }
33
+
34
+ function renderSpringDockerfile(unit) {
35
+ const buildCommand = joinCommand(unit.build.build);
36
+ const startCommand = joinCommand(unit.build.start);
37
+ return `FROM maven:3.9-eclipse-temurin-21 AS build
38
+ WORKDIR /app
39
+ COPY . .
40
+ RUN ${buildCommand}
41
+ FROM eclipse-temurin:21-jre
42
+ WORKDIR /app
43
+ COPY --from=build /app/target/*.jar /app/app.jar
44
+ COPY --from=build /app/build/libs/*.jar /app/app.jar
45
+ EXPOSE ${unit.port || 8080}
46
+ CMD ${JSON.stringify(['sh', '-lc', startCommand.replace('$(find target build/libs -name "*.jar" | head -n 1)', '/app/app.jar')])}
47
+ `;
48
+ }
49
+
50
+ function renderGoDockerfile(unit) {
51
+ const buildCommand = joinCommand(unit.build.build);
52
+ return `FROM golang:1.23 AS build
53
+ WORKDIR /app
54
+ COPY go.mod go.sum* ./
55
+ RUN go mod download
56
+ COPY . .
57
+ RUN ${buildCommand}
58
+ FROM alpine:3.20
59
+ WORKDIR /app
60
+ COPY --from=build /app/app /app/app
61
+ EXPOSE ${unit.port || 8080}
62
+ CMD ["./app"]
63
+ `;
64
+ }
65
+
66
+ function renderRustDockerfile(unit) {
67
+ return `FROM rust:1.82 AS build
68
+ WORKDIR /app
69
+ COPY . .
70
+ RUN cargo build --release
71
+ FROM debian:bookworm-slim
72
+ WORKDIR /app
73
+ COPY --from=build /app/target/release /app/target/release
74
+ EXPOSE ${unit.port || 3000}
75
+ CMD ["sh", "-lc", ${JSON.stringify(joinCommand(unit.build.start))}]
76
+ `;
77
+ }
78
+
79
+ function renderRubyDockerfile(unit) {
80
+ return `FROM ruby:3.3
81
+ WORKDIR /app
82
+ COPY Gemfile Gemfile.lock* ./
83
+ RUN bundle install
84
+ COPY . .
85
+ EXPOSE ${unit.port || 3000}
86
+ CMD ${JSON.stringify(['sh', '-lc', joinCommand(unit.build.start)])}
87
+ `;
88
+ }
89
+
90
+ function createGeneratedDockerfile(unit) {
91
+ if (['react', 'nextjs', 'vue', 'angular', 'nestjs'].includes(unit.framework)) {
92
+ return renderNodeDockerfile(unit);
93
+ }
94
+ if (unit.framework === 'django') {
95
+ return renderPythonDockerfile(unit);
96
+ }
97
+ if (unit.framework === 'springboot') {
98
+ return renderSpringDockerfile(unit);
99
+ }
100
+ if (['gin', 'echo', 'fiber'].includes(unit.framework)) {
101
+ return renderGoDockerfile(unit);
102
+ }
103
+ if (['axum', 'actix-web'].includes(unit.framework)) {
104
+ return renderRustDockerfile(unit);
105
+ }
106
+ if (['rails', 'sinatra'].includes(unit.framework)) {
107
+ return renderRubyDockerfile(unit);
108
+ }
109
+ throw new Error(`Unsupported Dockerfile generation for ${unit.framework}`);
110
+ }
111
+
112
+ module.exports = {
113
+ createGeneratedDockerfile,
114
+ };
@@ -0,0 +1,291 @@
1
+ function yamlValue(value) {
2
+ return JSON.stringify(String(value));
3
+ }
4
+
5
+ function renderKeyValueBlock(entries, indent) {
6
+ return Object.entries(entries)
7
+ .map(([key, value]) => `${' '.repeat(indent)}${key}: ${yamlValue(value)}`)
8
+ .join('\n');
9
+ }
10
+
11
+ function createSecretManifest(name, namespace, envMap) {
12
+ if (!envMap || Object.keys(envMap).length === 0) {
13
+ return null;
14
+ }
15
+ const encoded = Object.fromEntries(
16
+ Object.entries(envMap).map(([key, value]) => [key, Buffer.from(String(value)).toString('base64')]),
17
+ );
18
+ return `apiVersion: v1
19
+ kind: Secret
20
+ metadata:
21
+ name: ${name}
22
+ namespace: ${namespace}
23
+ type: Opaque
24
+ data:
25
+ ${renderKeyValueBlock(encoded, 2)}
26
+ `;
27
+ }
28
+
29
+ function createConfigMapManifest(name, namespace, envMap) {
30
+ if (!envMap || Object.keys(envMap).length === 0) {
31
+ return null;
32
+ }
33
+ return `apiVersion: v1
34
+ kind: ConfigMap
35
+ metadata:
36
+ name: ${name}
37
+ namespace: ${namespace}
38
+ data:
39
+ ${renderKeyValueBlock(envMap, 2)}
40
+ `;
41
+ }
42
+
43
+ function createTargetNodePlacement(indent = 6) {
44
+ const prefix = ' '.repeat(indent);
45
+ const nested = ' '.repeat(indent + 2);
46
+ const nestedMore = ' '.repeat(indent + 4);
47
+ return `${prefix}nodeSelector:
48
+ ${nested}k3s-deployer-target: "true"
49
+ ${prefix}tolerations:
50
+ ${nested}- key: node-role.kubernetes.io/control-plane
51
+ ${nestedMore}operator: Exists
52
+ ${nestedMore}effect: NoSchedule
53
+ ${nested}- key: node-role.kubernetes.io/master
54
+ ${nestedMore}operator: Exists
55
+ ${nestedMore}effect: NoSchedule`;
56
+ }
57
+
58
+ function createDeploymentManifest(spec) {
59
+ const imagePullPolicy =
60
+ spec.imagePullPolicy ||
61
+ (/^(localhost|127\.0\.0\.1)(:\d+)?\//.test(spec.image)
62
+ ? 'IfNotPresent'
63
+ : 'Always');
64
+ const envFrom = [];
65
+ if (spec.secretName) {
66
+ envFrom.push(
67
+ ` - secretRef:\n name: ${spec.secretName}`,
68
+ );
69
+ }
70
+ if (spec.configMapName) {
71
+ envFrom.push(
72
+ ` - configMapRef:\n name: ${spec.configMapName}`,
73
+ );
74
+ }
75
+ const volumeMounts = spec.volumeMountPath
76
+ ? ` volumeMounts:
77
+ - name: app-storage
78
+ mountPath: ${spec.volumeMountPath}`
79
+ : '';
80
+ const volumes = spec.volumeClaimName
81
+ ? ` volumes:
82
+ - name: app-storage
83
+ persistentVolumeClaim:
84
+ claimName: ${spec.volumeClaimName}`
85
+ : '';
86
+ return `apiVersion: apps/v1
87
+ kind: Deployment
88
+ metadata:
89
+ name: ${spec.name}
90
+ namespace: ${spec.namespace}
91
+ spec:
92
+ replicas: 1
93
+ selector:
94
+ matchLabels:
95
+ app: ${spec.name}
96
+ strategy:
97
+ type: RollingUpdate
98
+ rollingUpdate:
99
+ maxSurge: 25%
100
+ maxUnavailable: 25%
101
+ template:
102
+ metadata:
103
+ labels:
104
+ app: ${spec.name}
105
+ spec:
106
+ ${createTargetNodePlacement(6)}
107
+ containers:
108
+ - name: ${spec.name}
109
+ image: ${spec.image}
110
+ imagePullPolicy: ${imagePullPolicy}
111
+ ports:
112
+ - containerPort: ${spec.port}
113
+ ${envFrom.length ? ` envFrom:\n${envFrom.join('\n')}` : ''}${volumeMounts ? `\n${volumeMounts}` : ''}${volumes ? `\n${volumes}` : ''}
114
+ `;
115
+ }
116
+
117
+ function createServiceManifest(spec) {
118
+ const nodePort = spec.nodePort ? `\n nodePort: ${spec.nodePort}` : '';
119
+ return `apiVersion: v1
120
+ kind: Service
121
+ metadata:
122
+ name: ${spec.name}
123
+ namespace: ${spec.namespace}
124
+ spec:
125
+ type: ${spec.nodePort ? 'NodePort' : 'ClusterIP'}
126
+ selector:
127
+ app: ${spec.targetName || spec.name}
128
+ ports:
129
+ - protocol: TCP
130
+ port: ${spec.port}
131
+ targetPort: ${spec.targetPort || spec.port}${nodePort}
132
+ `;
133
+ }
134
+
135
+ function createIngressManifest(spec) {
136
+ return `apiVersion: networking.k8s.io/v1
137
+ kind: Ingress
138
+ metadata:
139
+ name: ${spec.name}
140
+ namespace: ${spec.namespace}
141
+ annotations:
142
+ kubernetes.io/ingress.class: nginx
143
+ cert-manager.io/cluster-issuer: letsencrypt-production
144
+ nginx.ingress.kubernetes.io/proxy-body-size: "0"
145
+ nginx.ingress.kubernetes.io/proxy-read-timeout: "120"
146
+ nginx.ingress.kubernetes.io/proxy-send-timeout: "120"
147
+ nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
148
+ spec:
149
+ ingressClassName: nginx
150
+ tls:
151
+ - hosts:
152
+ - ${spec.domain}
153
+ secretName: ${spec.tlsSecretName || `${spec.serviceName}-tls`}
154
+ rules:
155
+ - host: ${spec.domain}
156
+ http:
157
+ paths:
158
+ - path: /
159
+ pathType: Prefix
160
+ backend:
161
+ service:
162
+ name: ${spec.serviceName}
163
+ port:
164
+ number: ${spec.servicePort || 80}
165
+ `;
166
+ }
167
+
168
+ function createPersistentVolumeClaim(name, namespace, size) {
169
+ return `apiVersion: v1
170
+ kind: PersistentVolumeClaim
171
+ metadata:
172
+ name: ${name}
173
+ namespace: ${namespace}
174
+ spec:
175
+ accessModes:
176
+ - ReadWriteOnce
177
+ resources:
178
+ requests:
179
+ storage: ${size}
180
+ `;
181
+ }
182
+
183
+ function createDatabaseManifests(input) {
184
+ const manifests = [];
185
+ const serviceName = `${input.projectSlug}-${input.kind}`;
186
+
187
+ if (input.kind === 'sqlite') {
188
+ manifests.push(createPersistentVolumeClaim(`${serviceName}-pvc`, input.namespace, '1Gi'));
189
+ return manifests;
190
+ }
191
+
192
+ const pvcKinds = new Set(['postgresql', 'mysql', 'mongodb', 'elasticsearch']);
193
+ if (pvcKinds.has(input.kind)) {
194
+ manifests.push(createPersistentVolumeClaim(`${serviceName}-pvc`, input.namespace, '1Gi'));
195
+ }
196
+
197
+ const imageByKind = {
198
+ postgresql: 'postgres:16',
199
+ mysql: 'mysql:8.4',
200
+ mongodb: 'mongo:7',
201
+ redis: 'redis:7',
202
+ elasticsearch: 'docker.elastic.co/elasticsearch/elasticsearch:8.15.0',
203
+ };
204
+
205
+ const envLines = [];
206
+ if (input.kind === 'postgresql') {
207
+ envLines.push(' - name: POSTGRES_DB\n value: appdb');
208
+ envLines.push(' - name: POSTGRES_USER\n value: app');
209
+ envLines.push(' - name: POSTGRES_PASSWORD\n value: apppass');
210
+ }
211
+ if (input.kind === 'mysql') {
212
+ envLines.push(' - name: MYSQL_DATABASE\n value: appdb');
213
+ envLines.push(' - name: MYSQL_USER\n value: app');
214
+ envLines.push(' - name: MYSQL_PASSWORD\n value: apppass');
215
+ envLines.push(' - name: MYSQL_ROOT_PASSWORD\n value: rootpass');
216
+ }
217
+ if (input.kind === 'elasticsearch') {
218
+ envLines.push(' - name: discovery.type\n value: single-node');
219
+ envLines.push(' - name: xpack.security.enabled\n value: "false"');
220
+ }
221
+
222
+ const ports = {
223
+ postgresql: 5432,
224
+ mysql: 3306,
225
+ mongodb: 27017,
226
+ redis: 6379,
227
+ elasticsearch: 9200,
228
+ };
229
+
230
+ const mountPathByKind = {
231
+ postgresql: '/var/lib/postgresql/data',
232
+ mysql: '/var/lib/mysql',
233
+ mongodb: '/data/db',
234
+ elasticsearch: '/usr/share/elasticsearch/data',
235
+ };
236
+
237
+ const volumeMount = mountPathByKind[input.kind]
238
+ ? ` volumeMounts:
239
+ - name: data
240
+ mountPath: ${mountPathByKind[input.kind]}`
241
+ : '';
242
+ const volumes = mountPathByKind[input.kind]
243
+ ? ` volumes:
244
+ - name: data
245
+ persistentVolumeClaim:
246
+ claimName: ${serviceName}-pvc`
247
+ : '';
248
+
249
+ manifests.push(`apiVersion: apps/v1
250
+ kind: Deployment
251
+ metadata:
252
+ name: ${serviceName}
253
+ namespace: ${input.namespace}
254
+ spec:
255
+ replicas: 1
256
+ selector:
257
+ matchLabels:
258
+ app: ${serviceName}
259
+ template:
260
+ metadata:
261
+ labels:
262
+ app: ${serviceName}
263
+ spec:
264
+ ${createTargetNodePlacement(6)}
265
+ containers:
266
+ - name: ${serviceName}
267
+ image: ${imageByKind[input.kind]}
268
+ ports:
269
+ - containerPort: ${ports[input.kind]}
270
+ ${envLines.length ? ` env:\n${envLines.join('\n')}\n` : ''}${volumeMount ? `${volumeMount}\n` : ''}${volumes ? `${volumes}\n` : ''}`);
271
+
272
+ manifests.push(
273
+ createServiceManifest({
274
+ name: serviceName,
275
+ namespace: input.namespace,
276
+ port: ports[input.kind],
277
+ }),
278
+ );
279
+
280
+ return manifests;
281
+ }
282
+
283
+ module.exports = {
284
+ createConfigMapManifest,
285
+ createDatabaseManifests,
286
+ createDeploymentManifest,
287
+ createIngressManifest,
288
+ createPersistentVolumeClaim,
289
+ createSecretManifest,
290
+ createServiceManifest,
291
+ };