Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions actions/init-project.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Application>) => {
const { name: applicationName, resource: applicationResource } = resource
Expand Down Expand Up @@ -36,7 +37,7 @@ const addAdditionalApps = async (rootDirectory: string, repoURL: string) => {
],
})

if (!additionalAppChoices.length) {
if (additionalAppChoices.length === 0) {
return null
}

Expand Down Expand Up @@ -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

Expand All @@ -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],
})
Expand Down
16 changes: 16 additions & 0 deletions constants/init-values.ts
Original file line number Diff line number Diff line change
@@ -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',
},
}
5 changes: 5 additions & 0 deletions constants/paths.ts
Original file line number Diff line number Diff line change
@@ -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`
1 change: 1 addition & 0 deletions resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
170 changes: 170 additions & 0 deletions utils/composer-scaffold.ts
Original file line number Diff line number Diff line change
@@ -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<TemplateFile>, 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<string, unknown>
type YamlArray = Array<Record<string, unknown>>

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/<env> 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)),
])
}
43 changes: 35 additions & 8 deletions utils/get-project-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> }

// Prefer environments from profile.yaml; fallback to merged config (for backward compat) or ['dev']
const environments: Array<string> = (() => {
if (existsSync(profilePath)) {
try {
const prof = parse(readFileSync(profilePath).toString()) as { name?: string; environments?: Array<string> }
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)
15 changes: 8 additions & 7 deletions utils/override.util.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { isNil, mergeDeepWith } from 'ramda'
import { DeepPartial } from '../types'

export const override = <T extends object>(source: T, overrides?: DeepPartial<T>) =>
mergeDeepWith(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(from: any, to: any) => (isNil(to) ? from : to),
source,
overrides || {}
)
export const override = <T extends object>(source: T, overrides?: DeepPartial<T>): T => {
const replacer = <V>(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
}
15 changes: 15 additions & 0 deletions utils/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type ConfigInput = {
inputs?: Array<{ name: string }>
}

export type ScaffoldOptions = {
rootDirectory: string
overwrite?: boolean
mainRepositoryUrl: string
environments: Array<string>
}

export type TemplateFile = {
path: string
content: string
}