From 810f74eb544e558490e0ea6b124bfb3cd6f0c360 Mon Sep 17 00:00:00 2001 From: Grzegorz Wadas Date: Wed, 13 Aug 2025 14:43:23 +0200 Subject: [PATCH] feat: added init project structure --- actions/init-project.action.ts | 13 +-- constants/init-values.ts | 16 ++++ constants/paths.ts | 5 + resources/index.ts | 1 + utils/composer-scaffold.ts | 170 +++++++++++++++++++++++++++++++++ utils/get-project-config.ts | 43 +++++++-- utils/override.util.ts | 15 +-- utils/types.ts | 15 +++ 8 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 constants/init-values.ts create mode 100644 constants/paths.ts create mode 100644 utils/composer-scaffold.ts create mode 100644 utils/types.ts diff --git a/actions/init-project.action.ts b/actions/init-project.action.ts index b0e6ece..4df0de8 100644 --- a/actions/init-project.action.ts +++ b/actions/init-project.action.ts @@ -2,9 +2,10 @@ import { isNotNil } from 'ramda' import { checkbox, input } from '@inquirer/prompts' import { StacklessError } from '@codemaskjs/node-cli-toolkit' import { isRootDirectoryEmpty, override, writeYamlFile } from '../utils' -import { AddonResource, ProjectConfig } from '../types' +import { AddonResource } from '../types' import { CERT_MANAGER_ADDON_RESOURCE, IMAGE_UPDATER_ADDON_RESOURCE, INGRESS_NGINX_ADDON_RESOURCE, REFLECTOR_ADDON_RESOURCE } from '../addons' import { createAppProject, createApplication, Application, Kustomization } from '../resources' +import { scaffoldComposerFolder } from '../utils/composer-scaffold' const addAddonApplication = async (rootDirectory: string, addonsProjectName: string, resource: AddonResource) => { const { name: applicationName, resource: applicationResource } = resource @@ -36,7 +37,7 @@ const addAdditionalApps = async (rootDirectory: string, repoURL: string) => { ], }) - if (!additionalAppChoices.length) { + if (additionalAppChoices.length === 0) { return null } @@ -85,11 +86,6 @@ export const initProjectAction = async () => { .map(environment => environment.trim()) ) - const config: ProjectConfig = { - mainRepositoryUrl, - environments, - } - const addons = await addAdditionalApps(rootDirectory, mainRepositoryUrl) const addonsAddedInDefaultProject = addons?.addedInDefaultProject @@ -116,7 +112,8 @@ export const initProjectAction = async () => { } await writeYamlFile(`${rootDirectory}/root-app.yaml`, rootAppResource) - await writeYamlFile(`${rootDirectory}/argo-composer.config.yaml`, config) + // Generate the .argo-composer structure idempotently with summary output + await scaffoldComposerFolder({ rootDirectory, mainRepositoryUrl, environments }) await writeYamlFile(`${rootDirectory}/projects/kustomization.yaml`, { resources: !addonsAddedInDefaultProject ? ['./default', addons?.path].filter(isNotNil) : [addons.path], }) diff --git a/constants/init-values.ts b/constants/init-values.ts new file mode 100644 index 0000000..8a24a43 --- /dev/null +++ b/constants/init-values.ts @@ -0,0 +1,16 @@ +export const DEFAULT_PRESET_CONFIG = { + inputs: [ + { name: 'APP_NAME', required: true, description: 'Name of the application (DNS-safe).' }, + { name: 'NAMESPACE', required: true, description: 'Kubernetes namespace for the app.' }, + { name: 'REPO_URL', required: true, description: 'Git repository URL with manifests.' }, + { name: 'REVISION', required: false, default: 'main', description: 'Git revision/branch/tag.' }, + { name: 'IMAGE_NAME', required: true, description: 'Container image (e.g. ghcr.io/org/app:tag).' }, + { name: 'CONTAINER_PORT', required: true, description: 'Container port exposed by the app.' }, + { name: 'SERVICE_PORT', required: false, description: 'Service port; defaults to CONTAINER_PORT if unset.' }, + { name: 'ENV', required: false, default: 'dev', description: 'Environment name for overlays (e.g., dev).' }, + ], + help: { + summary: 'Default preset for a generic app.', + usage: 'argo-composer create app --profile default --preset default \\\n --set APP_NAME=myapp --set NAMESPACE=apps --set REPO_URL=https://example/repo.git', + }, +} diff --git a/constants/paths.ts b/constants/paths.ts new file mode 100644 index 0000000..8c6b608 --- /dev/null +++ b/constants/paths.ts @@ -0,0 +1,5 @@ +export const ARGO_COMPOSER_DIR = '.argo-composer' +export const DEFAULT_PROFILE = 'default' + +export const getProfileDir = (profile: string = DEFAULT_PROFILE) => `${ARGO_COMPOSER_DIR}/${profile}` +export const getTemplatesDir = (profile: string = DEFAULT_PROFILE) => `${getProfileDir(profile)}/templates` diff --git a/resources/index.ts b/resources/index.ts index f94bb65..8f145cf 100644 --- a/resources/index.ts +++ b/resources/index.ts @@ -6,5 +6,6 @@ export * from './create-horizontal-pod-autoscaler' export * from './create-config-map' export * from './create-service' export * from './create-ingress' +export * from './create-kustomization' export * from './get-image-updater-annotations' export * from './get-deployment-patches' diff --git a/utils/composer-scaffold.ts b/utils/composer-scaffold.ts new file mode 100644 index 0000000..67ed74a --- /dev/null +++ b/utils/composer-scaffold.ts @@ -0,0 +1,170 @@ +/* eslint-disable no-template-curly-in-string */ +import { join } from 'node:path' +import { ensureDir } from 'fs-extra' +import { parse, stringify } from 'yaml' +import { uniq } from 'ramda' +import { ARGO_COMPOSER_DIR, DEFAULT_PROFILE } from '../constants/paths' +import { writeYamlFile } from '../utils' +import { createApplication, createConfigMap, createIngress, createHorizontalPodAutoscaler, createKustomization } from '../resources' +import { ConfigInput, ScaffoldOptions, TemplateFile } from './types' +import { DEFAULT_PRESET_CONFIG } from '../constants/init-values' + +const listPlaceholders = (content: string) => { + const regex = /\$\{\{\s*input\.([A-Z0-9_]+)\s*\}\}/g + + return uniq(Array.from(content.matchAll(regex)).map(placeholder => placeholder[1])) +} + +const getPresetInputs = (presetConfigYaml: string) => { + const cfg = parse(presetConfigYaml) as ConfigInput + + return uniq(cfg.inputs?.map(input => input.name) ?? []) +} + +const validatePlaceholders = (templateFiles: Array, presetConfigYaml: string) => { + const inputs = getPresetInputs(presetConfigYaml) + + templateFiles.forEach(file => { + const placeholders = listPlaceholders(file.content) + + placeholders.forEach(placeholder => { + if (!inputs.includes(placeholder)) { + throw new Error(`Template placeholder not declared in preset inputs: ${placeholder} in ${file.path}`) + } + }) + }) +} + +export const scaffoldComposerFolder = async ({ rootDirectory, mainRepositoryUrl, environments }: ScaffoldOptions) => { + const root = join(process.cwd(), rootDirectory, ARGO_COMPOSER_DIR) + const templateDir = join(root, DEFAULT_PROFILE, 'presets', 'default', 'template') + const baseDir = join(templateDir, 'base') + const overlaysDir = join(templateDir, 'overlays') + + // create needed directories + await Promise.all([ensureDir(baseDir), ensureDir(overlaysDir)]) + + // File plans + const mergedConfigPath = join(root, 'argo-composer.yaml') + const mergedEffective = { + version: 2, + defaults: { profile: DEFAULT_PROFILE, preset: 'default' }, + profile: DEFAULT_PROFILE, + metadata: { owner: '', team: '' }, + mainRepositoryUrl: typeof mainRepositoryUrl === 'string' ? mainRepositoryUrl : '', + } + + const tplKustomizationPath = join(root, DEFAULT_PROFILE, 'presets', 'default', 'template', 'kustomization.yaml') + const tplKustomizationObj = createKustomization({ resources: ['application.yaml'] }) + + const tplApplicationPath = join(root, DEFAULT_PROFILE, 'presets', 'default', 'template', 'application.yaml') + const tplApplicationObj = createApplication({ + name: '${{ input.APP_NAME }}-${{ input.ENV }}', + namespace: '${{ input.NAMESPACE }}', + repoURL: '${{ input.REPO_URL }}', + project: 'default', + path: './overlays/${{ input.ENV }}', + }) + // App base files as objects + const baseConfigMapObj = createConfigMap({ applicationName: '${{ input.APP_NAME }}', data: {} }) + const baseDeploymentObj = { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: '${{ input.APP_NAME }}' }, + spec: { + replicas: 1, + selector: { matchLabels: { app: '${{ input.APP_NAME }}' } }, + template: { + metadata: { labels: { app: '${{ input.APP_NAME }}' } }, + spec: { + containers: [ + { + name: '${{ input.APP_NAME }}', + image: '${{ input.IMAGE_NAME }}', + ports: [{ containerPort: '${{ input.CONTAINER_PORT }}' }], + }, + ], + }, + }, + }, + } + + const baseServiceObj = { + apiVersion: 'v1', + kind: 'Service', + metadata: { name: '${{ input.APP_NAME }}' }, + spec: { + selector: { app: '${{ input.APP_NAME }}' }, + ports: [{ port: '${{ input.SERVICE_PORT }}', targetPort: '${{ input.CONTAINER_PORT }}' }], + }, + } + const baseIngressObj = createIngress({ applicationName: '${{ input.APP_NAME }}' }) + const baseHpaObj = createHorizontalPodAutoscaler({ applicationName: '${{ input.APP_NAME }}' }) + const baseKustomizationObj = createKustomization({ + resources: ['./configmap.yaml', './deployment.yaml', './service.yaml', './ingress.yaml', './hpa.yaml'], + }) + + // Overlays seed for each environment + type YamlObject = Record + type YamlArray = Array> + + const overlayPlans: Array<{ path: string; obj: YamlObject | YamlArray }> = environments.flatMap((env: string) => { + const envDir = join(overlaysDir, env) + const kustomizationObj = createKustomization({ + resources: ['../../base'], + // DeepPartial allows us to add patchesJson6902 here + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + patchesJson6902: [ + { + target: { group: 'apps', version: 'v1', kind: 'Deployment', name: '${{ input.APP_NAME }}' }, + path: './patches.yaml', + }, + ], + }) + + const patchesArr: YamlArray = [{ op: 'replace', path: '/spec/template/spec/containers/0/image', value: '${{ input.IMAGE_NAME }}' }] + return [ + { path: join(envDir, 'kustomization.yaml'), obj: kustomizationObj }, + { path: join(envDir, 'patches.yaml'), obj: patchesArr }, + ] + }) + + // Ensure each overlays/ directory exists + await Promise.all(environments.map((env: string) => ensureDir(join(overlaysDir, env)))) + + // Validate YAML config files parse by stringifying objects + parse(stringify(mergedEffective)) + parse(stringify(DEFAULT_PRESET_CONFIG)) + + // Validate placeholders declared across all template files + validatePlaceholders( + [ + { path: tplApplicationPath, content: stringify(tplApplicationObj) }, + { path: join(baseDir, 'configmap.yaml'), content: stringify(baseConfigMapObj) }, + { path: join(baseDir, 'deployment.yaml'), content: stringify(baseDeploymentObj) }, + { path: join(baseDir, 'service.yaml'), content: stringify(baseServiceObj) }, + { path: join(baseDir, 'ingress.yaml'), content: stringify(baseIngressObj) }, + { path: join(baseDir, 'hpa.yaml'), content: stringify(baseHpaObj) }, + { path: join(baseDir, 'kustomization.yaml'), content: stringify(baseKustomizationObj) }, + ...overlayPlans.map(p => ({ path: p.path, content: stringify(p.obj) })), + ], + stringify(DEFAULT_PRESET_CONFIG) + ) + + // Write files using YAML writer + await Promise.all([ + writeYamlFile(mergedConfigPath, mergedEffective), + writeYamlFile(join(root, DEFAULT_PROFILE, 'profile.yaml'), { name: DEFAULT_PROFILE, environments }), + writeYamlFile(join(root, DEFAULT_PROFILE, 'presets', 'default', 'preset.yaml'), DEFAULT_PRESET_CONFIG), + writeYamlFile(tplKustomizationPath, tplKustomizationObj), + writeYamlFile(tplApplicationPath, tplApplicationObj), + writeYamlFile(join(baseDir, 'configmap.yaml'), baseConfigMapObj), + writeYamlFile(join(baseDir, 'deployment.yaml'), baseDeploymentObj), + writeYamlFile(join(baseDir, 'service.yaml'), baseServiceObj), + writeYamlFile(join(baseDir, 'ingress.yaml'), baseIngressObj), + writeYamlFile(join(baseDir, 'hpa.yaml'), baseHpaObj), + writeYamlFile(join(baseDir, 'kustomization.yaml'), baseKustomizationObj), + ...overlayPlans.map(p => writeYamlFile(p.path, p.obj)), + ]) +} diff --git a/utils/get-project-config.ts b/utils/get-project-config.ts index c5e6698..8668aa8 100644 --- a/utils/get-project-config.ts +++ b/utils/get-project-config.ts @@ -3,21 +3,48 @@ import { existsSync, readFileSync } from 'fs-extra' import { join } from 'node:path' import { StacklessError } from '@codemaskjs/node-cli-toolkit' import { ProjectConfig } from '../types' +import { ARGO_COMPOSER_DIR, DEFAULT_PROFILE } from '../constants/paths' -const projectConfigPath = join(process.cwd(), './argo-composer.config.yaml') +// v2+: configuration moved under a dedicated `.argo-composer` directory +const mergedConfigPath = join(process.cwd(), `${ARGO_COMPOSER_DIR}/argo-composer.yaml`) +const profilePath = join(process.cwd(), `${ARGO_COMPOSER_DIR}/${DEFAULT_PROFILE}/profile.yaml`) export const getProjectConfig = (): ProjectConfig => { - if (!existsSync(projectConfigPath)) { - throw new StacklessError(`Argo Composer cannot 'find argo-composer.config.yaml'. Was the root directory initialized properly?`) + if (!existsSync(mergedConfigPath)) { + throw new StacklessError(`Argo Composer cannot find '.argo-composer/argo-composer.yaml'. Was the root directory initialized properly?`) } - const file = readFileSync(projectConfigPath).toString() - - if (!file) { + const mergedRaw = readFileSync(mergedConfigPath).toString() + if (mergedRaw.trim().length === 0) { throw new Error('No project initialized found! Please start from init command!') } - return parse(file) + const merged = parse(mergedRaw) as { mainRepositoryUrl?: string; environments?: Array } + + // Prefer environments from profile.yaml; fallback to merged config (for backward compat) or ['dev'] + const environments: Array = (() => { + if (existsSync(profilePath)) { + try { + const prof = parse(readFileSync(profilePath).toString()) as { name?: string; environments?: Array } + if (Array.isArray(prof.environments) && prof.environments.length > 0) { + return prof.environments + } + } catch { + // ignore and continue to fallbacks + } + } + + if (Array.isArray(merged.environments) && merged.environments.length > 0) { + return merged.environments + } + + return ['dev'] + })() + + return { + mainRepositoryUrl: merged.mainRepositoryUrl ?? '', + environments, + } } -export const isProjectExists = () => existsSync(projectConfigPath) +export const isProjectExists = () => existsSync(mergedConfigPath) diff --git a/utils/override.util.ts b/utils/override.util.ts index 6de99c6..04273d3 100644 --- a/utils/override.util.ts +++ b/utils/override.util.ts @@ -1,10 +1,11 @@ import { isNil, mergeDeepWith } from 'ramda' import { DeepPartial } from '../types' -export const override = (source: T, overrides?: DeepPartial) => - mergeDeepWith( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (from: any, to: any) => (isNil(to) ? from : to), - source, - overrides || {} - ) +export const override = (source: T, overrides?: DeepPartial): T => { + const replacer = (from: V, to: V): V => (isNil(to) ? from : to) + + // Constrain typings at the boundary; return strongly typed T + const result = mergeDeepWith(replacer as (from: unknown, to: unknown) => unknown, source as unknown, (overrides ?? {}) as unknown) as T + + return result +} diff --git a/utils/types.ts b/utils/types.ts new file mode 100644 index 0000000..b590598 --- /dev/null +++ b/utils/types.ts @@ -0,0 +1,15 @@ +export type ConfigInput = { + inputs?: Array<{ name: string }> +} + +export type ScaffoldOptions = { + rootDirectory: string + overwrite?: boolean + mainRepositoryUrl: string + environments: Array +} + +export type TemplateFile = { + path: string + content: string +}