@vertile-ai/iac 0.0.1
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 +255 -0
- package/docs/README.md +40 -0
- package/docs/index.html +276 -0
- package/docs/manifest.md +130 -0
- package/docs/positioning.md +65 -0
- package/docs/roadmap.md +77 -0
- package/examples/dynomic/README.md +22 -0
- package/examples/dynomic/apps/admin/package.json +7 -0
- package/examples/dynomic/apps/admin/vercel.json +4 -0
- package/examples/dynomic/apps/web/package.json +7 -0
- package/examples/dynomic/apps/web/vercel.json +4 -0
- package/examples/dynomic/infrastructure/iac/iac.json +191 -0
- package/examples/dynomic/package.json +17 -0
- package/package.json +51 -0
- package/schema/iac.schema.json +266 -0
- package/src/apply.mjs +38 -0
- package/src/cli.mjs +74 -0
- package/src/core/args.mjs +36 -0
- package/src/core/concepts.mjs +26 -0
- package/src/core/context.mjs +39 -0
- package/src/core/hcl.mjs +110 -0
- package/src/core/manifest.mjs +110 -0
- package/src/core/render.mjs +38 -0
- package/src/core/terraform.mjs +42 -0
- package/src/core/vercel-manifests.mjs +179 -0
- package/src/plan.mjs +32 -0
- package/src/providers/aws/index.mjs +153 -0
- package/src/providers/digitalocean/index.mjs +85 -0
- package/src/providers/vercel/index.mjs +60 -0
- package/src/provision-env.mjs +798 -0
- package/src/reconcile-project-domains.mjs +549 -0
- package/src/reconcile-project-settings.mjs +385 -0
- package/src/render.mjs +27 -0
- package/src/shared.mjs +116 -0
- package/src/sync-env.mjs +297 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
|
|
3
|
+
function asObject(value, fallback = {}) {
|
|
4
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : fallback
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function normalizeProject(project) {
|
|
8
|
+
if (typeof project === 'string') return { name: project }
|
|
9
|
+
const normalized = asObject(project)
|
|
10
|
+
return {
|
|
11
|
+
name: normalized.name || normalized.key || 'project',
|
|
12
|
+
...normalized,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeApp(app) {
|
|
17
|
+
const normalized = asObject(app)
|
|
18
|
+
if (!normalized.key) {
|
|
19
|
+
throw new Error('Each iac.json app must include a key.')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
name: normalized.name || normalized.key,
|
|
24
|
+
...normalized,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeKeyedList(manifest, field) {
|
|
29
|
+
if (!Array.isArray(manifest[field])) return []
|
|
30
|
+
|
|
31
|
+
return manifest[field].map((item) => {
|
|
32
|
+
const normalized = asObject(item)
|
|
33
|
+
if (!normalized.key) {
|
|
34
|
+
throw new Error(`Each iac.json ${field} item must include a key.`)
|
|
35
|
+
}
|
|
36
|
+
return normalized
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertStringArray(value, field) {
|
|
41
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.trim() === '')) {
|
|
42
|
+
throw new Error(`iac.json ${field} must be an array of non-empty strings.`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateProviderResources(providers) {
|
|
47
|
+
for (const [provider, config] of Object.entries(providers)) {
|
|
48
|
+
const resources = config && Array.isArray(config.resources) ? config.resources : []
|
|
49
|
+
for (const resource of resources) {
|
|
50
|
+
if (!resource.type || !resource.name) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`iac.json providers.${provider}.resources items must include type and name.`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function validateManifest(manifest) {
|
|
60
|
+
if (manifest.version !== 1) {
|
|
61
|
+
throw new Error(`Unsupported iac.json version "${manifest.version}". Expected version 1.`)
|
|
62
|
+
}
|
|
63
|
+
if (!manifest.project.name || typeof manifest.project.name !== 'string') {
|
|
64
|
+
throw new Error('iac.json project.name must be a non-empty string.')
|
|
65
|
+
}
|
|
66
|
+
assertStringArray(manifest.environments, 'environments')
|
|
67
|
+
validateProviderResources(manifest.providers)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function normalizeManifest(rawManifest) {
|
|
71
|
+
const manifest = asObject(rawManifest)
|
|
72
|
+
const environments = Array.isArray(manifest.environments)
|
|
73
|
+
? manifest.environments
|
|
74
|
+
: ['development', 'preview', 'production']
|
|
75
|
+
|
|
76
|
+
const normalized = {
|
|
77
|
+
version: manifest.version || 1,
|
|
78
|
+
project: normalizeProject(manifest.project),
|
|
79
|
+
environments,
|
|
80
|
+
providers: asObject(manifest.providers),
|
|
81
|
+
apps: Array.isArray(manifest.apps) ? manifest.apps.map(normalizeApp) : [],
|
|
82
|
+
domains: Array.isArray(manifest.domains) ? manifest.domains : [],
|
|
83
|
+
infraDir: manifest.infraDir,
|
|
84
|
+
objectStorage: normalizeKeyedList(manifest, 'objectStorage'),
|
|
85
|
+
databases: normalizeKeyedList(manifest, 'databases'),
|
|
86
|
+
queues: normalizeKeyedList(manifest, 'queues'),
|
|
87
|
+
sandboxes: normalizeKeyedList(manifest, 'sandboxes'),
|
|
88
|
+
clusters: normalizeKeyedList(manifest, 'clusters'),
|
|
89
|
+
env: asObject(manifest.env),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
validateManifest(normalized)
|
|
93
|
+
return normalized
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readManifest(filePath) {
|
|
97
|
+
if (!fs.existsSync(filePath)) {
|
|
98
|
+
throw new Error(`Missing required iac manifest: ${filePath}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return normalizeManifest(JSON.parse(fs.readFileSync(filePath, 'utf8')))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function assertEnvironment(manifest, environment) {
|
|
105
|
+
if (!manifest.environments.includes(environment)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Unknown environment "${environment}". Use one of: ${manifest.environments.join(', ')}`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { targetWorkspace } from './context.mjs'
|
|
4
|
+
import { renderTerraform as renderAws } from '../providers/aws/index.mjs'
|
|
5
|
+
import { renderTerraform as renderDigitalOcean } from '../providers/digitalocean/index.mjs'
|
|
6
|
+
import { renderTerraform as renderVercel } from '../providers/vercel/index.mjs'
|
|
7
|
+
|
|
8
|
+
const renderers = {
|
|
9
|
+
aws: renderAws,
|
|
10
|
+
digitalocean: renderDigitalOcean,
|
|
11
|
+
vercel: renderVercel,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderTarget({ manifest, environment, target }) {
|
|
15
|
+
const render = renderers[target]
|
|
16
|
+
if (!render) throw new Error(`No renderer registered for target "${target}".`)
|
|
17
|
+
return render({ manifest, environment })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function writeTarget({ context, manifest, environment, target }) {
|
|
21
|
+
const workspace = targetWorkspace(context, target)
|
|
22
|
+
const files = renderTarget({ manifest, environment, target })
|
|
23
|
+
await fs.mkdir(workspace, { recursive: true })
|
|
24
|
+
|
|
25
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
26
|
+
await fs.writeFile(path.join(workspace, name), contents)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { workspace, files }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function writeTargets({ context, manifest, environment, targets }) {
|
|
33
|
+
const rendered = []
|
|
34
|
+
for (const target of targets) {
|
|
35
|
+
rendered.push(await writeTarget({ context, manifest, environment, target }))
|
|
36
|
+
}
|
|
37
|
+
return rendered
|
|
38
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
function runTerraform({ terraformBin, workspace, args }) {
|
|
4
|
+
const result = spawnSync(terraformBin, args, {
|
|
5
|
+
cwd: workspace,
|
|
6
|
+
stdio: 'inherit',
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
if (result.error) {
|
|
10
|
+
throw new Error(`Failed to run ${terraformBin}: ${result.error.message}`)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (result.status !== 0) {
|
|
14
|
+
throw new Error(`${terraformBin} ${args.join(' ')} failed with exit code ${result.status}`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function terraformPlan({ terraformBin, workspace }) {
|
|
19
|
+
runTerraform({
|
|
20
|
+
terraformBin,
|
|
21
|
+
workspace,
|
|
22
|
+
args: ['init', '-input=false'],
|
|
23
|
+
})
|
|
24
|
+
runTerraform({
|
|
25
|
+
terraformBin,
|
|
26
|
+
workspace,
|
|
27
|
+
args: ['plan', '-input=false'],
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function terraformApply({ terraformBin, workspace, autoApprove = false }) {
|
|
32
|
+
runTerraform({
|
|
33
|
+
terraformBin,
|
|
34
|
+
workspace,
|
|
35
|
+
args: ['init', '-input=false'],
|
|
36
|
+
})
|
|
37
|
+
runTerraform({
|
|
38
|
+
terraformBin,
|
|
39
|
+
workspace,
|
|
40
|
+
args: ['apply', '-input=false', ...(autoApprove ? ['-auto-approve'] : [])],
|
|
41
|
+
})
|
|
42
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { readManifest } from './manifest.mjs'
|
|
3
|
+
|
|
4
|
+
const projectSettingKeys = [
|
|
5
|
+
'rootDirectory',
|
|
6
|
+
'nodeVersion',
|
|
7
|
+
'enableAffectedProjectsDeployments',
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
function readJSON(filePath) {
|
|
11
|
+
if (!fs.existsSync(filePath)) {
|
|
12
|
+
throw new Error(`Missing required file: ${filePath}`)
|
|
13
|
+
}
|
|
14
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readUnifiedManifest(context, fallbackPath) {
|
|
18
|
+
if (!fs.existsSync(context.iacManifestPath)) {
|
|
19
|
+
throw new Error(`Missing required file: ${fallbackPath} or ${context.iacManifestPath}`)
|
|
20
|
+
}
|
|
21
|
+
return readManifest(context.iacManifestPath)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readLegacyOrUnified({
|
|
25
|
+
legacyPath,
|
|
26
|
+
explicitLegacyPath,
|
|
27
|
+
context,
|
|
28
|
+
derive,
|
|
29
|
+
}) {
|
|
30
|
+
if (fs.existsSync(legacyPath) || explicitLegacyPath) {
|
|
31
|
+
return readJSON(legacyPath)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return derive(readUnifiedManifest(context, legacyPath))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function vercelConfig(manifest) {
|
|
38
|
+
return manifest.providers.vercel || {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function appVercelValues(app) {
|
|
42
|
+
return {
|
|
43
|
+
...app,
|
|
44
|
+
...((app.providers && app.providers.vercel) || {}),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function teamSlugFromManifest(manifest) {
|
|
49
|
+
const config = vercelConfig(manifest)
|
|
50
|
+
return config.teamSlug || config.team || ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function envSourceDir(manifest) {
|
|
54
|
+
return (
|
|
55
|
+
manifest.env.sourceDir ||
|
|
56
|
+
manifest.env.dir ||
|
|
57
|
+
manifest.env.infraDir ||
|
|
58
|
+
manifest.infraDir ||
|
|
59
|
+
'infrastructure'
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function configuredProjects(manifest) {
|
|
64
|
+
return manifest.apps.map((app) => {
|
|
65
|
+
const values = appVercelValues(app)
|
|
66
|
+
return {
|
|
67
|
+
key: app.key,
|
|
68
|
+
id: values.id || values.projectId || '',
|
|
69
|
+
name: values.name || app.name || app.key,
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function compactObject(value) {
|
|
75
|
+
return Object.fromEntries(
|
|
76
|
+
Object.entries(value).filter(([, item]) => item !== undefined),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function projectSettings(manifest) {
|
|
81
|
+
return manifest.apps.map((app) => {
|
|
82
|
+
const values = appVercelValues(app)
|
|
83
|
+
const entry = { key: app.key }
|
|
84
|
+
for (const key of projectSettingKeys) {
|
|
85
|
+
if (values[key] !== undefined) entry[key] = values[key]
|
|
86
|
+
}
|
|
87
|
+
return entry
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function domainTarget(domain, fallback) {
|
|
92
|
+
if (fallback) return fallback
|
|
93
|
+
if (!domain || typeof domain !== 'object') return ''
|
|
94
|
+
return domain.app || domain.project || domain.key || ''
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function domainConfig(domain) {
|
|
98
|
+
if (typeof domain === 'string') return domain
|
|
99
|
+
if (!domain || typeof domain !== 'object') return null
|
|
100
|
+
|
|
101
|
+
return compactObject({
|
|
102
|
+
name: domain.name,
|
|
103
|
+
gitBranch: domain.gitBranch,
|
|
104
|
+
verified: domain.verified,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function projectDomains(manifest) {
|
|
109
|
+
const domainsByProject = new Map(manifest.apps.map((app) => [app.key, []]))
|
|
110
|
+
|
|
111
|
+
for (const app of manifest.apps) {
|
|
112
|
+
const values = appVercelValues(app)
|
|
113
|
+
const domains = Array.isArray(values.domains) ? values.domains : []
|
|
114
|
+
for (const domain of domains) {
|
|
115
|
+
const config = domainConfig(domain)
|
|
116
|
+
if (config) domainsByProject.get(app.key).push(config)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const domain of manifest.domains) {
|
|
121
|
+
const target = domainTarget(domain)
|
|
122
|
+
if (!target) continue
|
|
123
|
+
const config = domainConfig(domain)
|
|
124
|
+
if (!config) continue
|
|
125
|
+
if (!domainsByProject.has(target)) domainsByProject.set(target, [])
|
|
126
|
+
domainsByProject.get(target).push(config)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return [...domainsByProject.entries()].map(([key, domains]) => ({ key, domains }))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function vercelEnvManifestFromIac(manifest, context = {}) {
|
|
133
|
+
return {
|
|
134
|
+
teamSlug: teamSlugFromManifest(manifest),
|
|
135
|
+
infraDir: context.infraDir || envSourceDir(manifest),
|
|
136
|
+
projects: configuredProjects(manifest),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function vercelProjectSettingsFromIac(manifest) {
|
|
141
|
+
const config = vercelConfig(manifest)
|
|
142
|
+
return compactObject({
|
|
143
|
+
defaults: config.projectDefaults || config.projectSettingsDefaults,
|
|
144
|
+
projects: projectSettings(manifest),
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function vercelProjectDomainsFromIac(manifest) {
|
|
149
|
+
return {
|
|
150
|
+
projects: projectDomains(manifest),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function readVercelEnvManifest(context) {
|
|
155
|
+
return readLegacyOrUnified({
|
|
156
|
+
legacyPath: context.manifestPath,
|
|
157
|
+
explicitLegacyPath: context.explicitManifestPath,
|
|
158
|
+
context,
|
|
159
|
+
derive: (manifest) => vercelEnvManifestFromIac(manifest, context),
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function readVercelProjectSettingsManifest(context) {
|
|
164
|
+
return readLegacyOrUnified({
|
|
165
|
+
legacyPath: context.projectSettingsPath,
|
|
166
|
+
explicitLegacyPath: context.explicitProjectSettingsPath,
|
|
167
|
+
context,
|
|
168
|
+
derive: vercelProjectSettingsFromIac,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function readVercelProjectDomainsManifest(context) {
|
|
173
|
+
return readLegacyOrUnified({
|
|
174
|
+
legacyPath: context.projectDomainsPath,
|
|
175
|
+
explicitLegacyPath: context.explicitProjectDomainsPath,
|
|
176
|
+
context,
|
|
177
|
+
derive: vercelProjectDomainsFromIac,
|
|
178
|
+
})
|
|
179
|
+
}
|
package/src/plan.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { parseTargetOption, readOption } from './core/args.mjs'
|
|
5
|
+
import { resolvePlatformContext } from './core/context.mjs'
|
|
6
|
+
import { assertEnvironment, readManifest } from './core/manifest.mjs'
|
|
7
|
+
import { writeTargets } from './core/render.mjs'
|
|
8
|
+
import { terraformPlan } from './core/terraform.mjs'
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const argv = process.argv.slice(2)
|
|
12
|
+
const context = resolvePlatformContext(argv)
|
|
13
|
+
const manifest = readManifest(context.manifestPath)
|
|
14
|
+
const environment = readOption(argv, '--env') || 'production'
|
|
15
|
+
const targets = parseTargetOption(argv)
|
|
16
|
+
|
|
17
|
+
assertEnvironment(manifest, environment)
|
|
18
|
+
|
|
19
|
+
const rendered = await writeTargets({ context, manifest, environment, targets })
|
|
20
|
+
for (const item of rendered) {
|
|
21
|
+
console.log(`Planning ${item.workspace}`)
|
|
22
|
+
terraformPlan({
|
|
23
|
+
terraformBin: context.terraformBin,
|
|
24
|
+
workspace: item.workspace,
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
main().catch((error) => {
|
|
30
|
+
console.error(error.message)
|
|
31
|
+
process.exit(1)
|
|
32
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
block,
|
|
3
|
+
nestedBlock,
|
|
4
|
+
raw,
|
|
5
|
+
renderGenericResources,
|
|
6
|
+
renderLocals,
|
|
7
|
+
renderOutput,
|
|
8
|
+
renderRequiredProvider,
|
|
9
|
+
renderVariable,
|
|
10
|
+
} from '../../core/hcl.mjs'
|
|
11
|
+
import {
|
|
12
|
+
compactBody,
|
|
13
|
+
providerResourceName,
|
|
14
|
+
providerValues,
|
|
15
|
+
resourceName,
|
|
16
|
+
terraformVariableName,
|
|
17
|
+
} from '../../core/concepts.mjs'
|
|
18
|
+
|
|
19
|
+
function providerBlock(manifest, environment) {
|
|
20
|
+
const config = manifest.providers.aws || {}
|
|
21
|
+
const body = {
|
|
22
|
+
region: config.region,
|
|
23
|
+
}
|
|
24
|
+
const tags = {
|
|
25
|
+
Project: manifest.project.name,
|
|
26
|
+
Environment: environment,
|
|
27
|
+
...(config.defaultTags || {}),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const provider = block('provider', ['aws'], body)
|
|
31
|
+
if (Object.keys(tags).length === 0) return provider
|
|
32
|
+
|
|
33
|
+
return `${provider.slice(0, -1)}\n ${nestedBlock('default_tags', { tags }).replace(/\n/g, '\n ')}\n}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function objectStorageBlocks(manifest, environment) {
|
|
37
|
+
return manifest.objectStorage.map((item) => {
|
|
38
|
+
const values = providerValues(item, 'aws')
|
|
39
|
+
return block('resource', ['aws_s3_bucket', resourceName('object_storage', item.key)], compactBody({
|
|
40
|
+
bucket: values.bucket || providerResourceName(manifest, environment, item),
|
|
41
|
+
force_destroy: values.forceDestroy,
|
|
42
|
+
}))
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function queueBlocks(manifest, environment) {
|
|
47
|
+
return manifest.queues.map((item) => {
|
|
48
|
+
const values = providerValues(item, 'aws')
|
|
49
|
+
const fifo = values.fifo || item.kind === 'fifo'
|
|
50
|
+
const baseName = values.name || providerResourceName(manifest, environment, item)
|
|
51
|
+
return block('resource', ['aws_sqs_queue', resourceName('queue', item.key)], compactBody({
|
|
52
|
+
name: fifo && !baseName.endsWith('.fifo') ? `${baseName}.fifo` : baseName,
|
|
53
|
+
fifo_queue: fifo || undefined,
|
|
54
|
+
visibility_timeout_seconds: values.visibilityTimeoutSeconds,
|
|
55
|
+
message_retention_seconds: values.messageRetentionSeconds,
|
|
56
|
+
}))
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function databaseBlocks(manifest, environment) {
|
|
61
|
+
return manifest.databases.map((item) => {
|
|
62
|
+
const values = providerValues(item, 'aws')
|
|
63
|
+
const variable = terraformVariableName('database', item.key, 'password')
|
|
64
|
+
return block('resource', ['aws_db_instance', resourceName('database', item.key)], compactBody({
|
|
65
|
+
identifier: values.identifier || providerResourceName(manifest, environment, item),
|
|
66
|
+
engine: values.engine || item.engine || 'postgres',
|
|
67
|
+
engine_version: values.engineVersion,
|
|
68
|
+
instance_class: values.instanceClass || 'db.t4g.micro',
|
|
69
|
+
allocated_storage: values.allocatedStorage || 20,
|
|
70
|
+
db_name: values.databaseName || resourceName(item.key),
|
|
71
|
+
username: values.username || 'app',
|
|
72
|
+
password: raw(`var.${variable}`),
|
|
73
|
+
publicly_accessible: values.publiclyAccessible || false,
|
|
74
|
+
skip_final_snapshot: values.skipFinalSnapshot ?? true,
|
|
75
|
+
}))
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function databaseVariables(manifest) {
|
|
80
|
+
return manifest.databases.map((item) => renderVariable(
|
|
81
|
+
terraformVariableName('database', item.key, 'password'),
|
|
82
|
+
{
|
|
83
|
+
type: raw('string'),
|
|
84
|
+
sensitive: true,
|
|
85
|
+
description: `Password for the ${item.key} database.`,
|
|
86
|
+
},
|
|
87
|
+
))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function amazonLinuxDataSource() {
|
|
91
|
+
return [
|
|
92
|
+
'data "aws_ami" "amazon_linux" {',
|
|
93
|
+
' most_recent = true',
|
|
94
|
+
' owners = ["amazon"]',
|
|
95
|
+
'',
|
|
96
|
+
' filter {',
|
|
97
|
+
' name = "name"',
|
|
98
|
+
' values = ["al2023-ami-*-x86_64"]',
|
|
99
|
+
' }',
|
|
100
|
+
'}',
|
|
101
|
+
].join('\n')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function computeBlocks(manifest, environment, field, prefix) {
|
|
105
|
+
return manifest[field].map((item) => {
|
|
106
|
+
const values = providerValues(item, 'aws')
|
|
107
|
+
return block('resource', ['aws_instance', resourceName(prefix, item.key)], compactBody({
|
|
108
|
+
ami: values.ami ? values.ami : raw('data.aws_ami.amazon_linux.id'),
|
|
109
|
+
instance_type: values.instanceType || 't3.micro',
|
|
110
|
+
count: field === 'clusters' ? values.size || item.size || 1 : undefined,
|
|
111
|
+
tags: {
|
|
112
|
+
Name: values.name || providerResourceName(manifest, environment, item),
|
|
113
|
+
Project: manifest.project.name,
|
|
114
|
+
Environment: environment,
|
|
115
|
+
Kind: prefix,
|
|
116
|
+
},
|
|
117
|
+
}))
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function outputBlocks(manifest) {
|
|
122
|
+
return manifest.objectStorage.map((item) => renderOutput(
|
|
123
|
+
resourceName('aws_object_storage', item.key, 'bucket_name'),
|
|
124
|
+
{
|
|
125
|
+
value: raw(`aws_s3_bucket.${resourceName('object_storage', item.key)}.bucket`),
|
|
126
|
+
},
|
|
127
|
+
))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function renderTerraform({ manifest, environment }) {
|
|
131
|
+
const config = manifest.providers.aws || {}
|
|
132
|
+
const resources = renderGenericResources(config.resources)
|
|
133
|
+
const needsAmazonLinux = manifest.sandboxes.length > 0 || manifest.clusters.length > 0
|
|
134
|
+
const mainBlocks = [
|
|
135
|
+
renderLocals(manifest, environment),
|
|
136
|
+
providerBlock(manifest, environment),
|
|
137
|
+
needsAmazonLinux ? amazonLinuxDataSource() : '',
|
|
138
|
+
...objectStorageBlocks(manifest, environment),
|
|
139
|
+
...queueBlocks(manifest, environment),
|
|
140
|
+
...databaseBlocks(manifest, environment),
|
|
141
|
+
...computeBlocks(manifest, environment, 'sandboxes', 'sandbox'),
|
|
142
|
+
...computeBlocks(manifest, environment, 'clusters', 'cluster'),
|
|
143
|
+
resources,
|
|
144
|
+
].filter(Boolean)
|
|
145
|
+
const variableBlocks = databaseVariables(manifest)
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
'versions.tf': `${renderRequiredProvider('aws', 'hashicorp/aws', '>= 5.0.0')}\n`,
|
|
149
|
+
'main.tf': `${mainBlocks.join('\n\n')}\n`,
|
|
150
|
+
...(variableBlocks.length > 0 ? { 'variables.tf': `${variableBlocks.join('\n\n')}\n` } : {}),
|
|
151
|
+
...(outputBlocks(manifest).length > 0 ? { 'outputs.tf': `${outputBlocks(manifest).join('\n\n')}\n` } : {}),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
block,
|
|
3
|
+
renderGenericResources,
|
|
4
|
+
renderLocals,
|
|
5
|
+
renderOutput,
|
|
6
|
+
renderRequiredProvider,
|
|
7
|
+
} from '../../core/hcl.mjs'
|
|
8
|
+
import {
|
|
9
|
+
compactBody,
|
|
10
|
+
providerResourceName,
|
|
11
|
+
providerValues,
|
|
12
|
+
resourceName,
|
|
13
|
+
} from '../../core/concepts.mjs'
|
|
14
|
+
|
|
15
|
+
function region(config, values) {
|
|
16
|
+
return values.region || config.region || 'nyc3'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function objectStorageBlocks(manifest, environment, config) {
|
|
20
|
+
return manifest.objectStorage.map((item) => {
|
|
21
|
+
const values = providerValues(item, 'digitalocean')
|
|
22
|
+
return block('resource', ['digitalocean_spaces_bucket', resourceName('object_storage', item.key)], compactBody({
|
|
23
|
+
name: values.name || providerResourceName(manifest, environment, item),
|
|
24
|
+
region: region(config, values),
|
|
25
|
+
acl: values.acl || 'private',
|
|
26
|
+
}))
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function databaseBlocks(manifest, environment, config) {
|
|
31
|
+
return manifest.databases.map((item) => {
|
|
32
|
+
const values = providerValues(item, 'digitalocean')
|
|
33
|
+
return block('resource', ['digitalocean_database_cluster', resourceName('database', item.key)], compactBody({
|
|
34
|
+
name: values.name || providerResourceName(manifest, environment, item),
|
|
35
|
+
engine: values.engine || item.engine || 'pg',
|
|
36
|
+
version: values.version || '15',
|
|
37
|
+
size: values.size || 'db-s-1vcpu-1gb',
|
|
38
|
+
region: region(config, values),
|
|
39
|
+
node_count: values.nodeCount || 1,
|
|
40
|
+
}))
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function dropletBlocks(manifest, environment, config, field, prefix) {
|
|
45
|
+
return manifest[field].map((item) => {
|
|
46
|
+
const values = providerValues(item, 'digitalocean')
|
|
47
|
+
return block('resource', ['digitalocean_droplet', resourceName(prefix, item.key)], compactBody({
|
|
48
|
+
name: values.name || providerResourceName(manifest, environment, item),
|
|
49
|
+
image: values.image || 'ubuntu-24-04-x64',
|
|
50
|
+
region: region(config, values),
|
|
51
|
+
size: values.sizeSlug || values.size || 's-1vcpu-1gb',
|
|
52
|
+
count: field === 'clusters' ? values.nodes || item.nodes || item.size || 1 : undefined,
|
|
53
|
+
tags: [manifest.project.name, environment, prefix],
|
|
54
|
+
}))
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function outputBlocks(manifest) {
|
|
59
|
+
return manifest.objectStorage.map((item) => renderOutput(
|
|
60
|
+
resourceName('digitalocean_object_storage', item.key, 'bucket_name'),
|
|
61
|
+
{
|
|
62
|
+
value: `\${digitalocean_spaces_bucket.${resourceName('object_storage', item.key)}.name}`,
|
|
63
|
+
},
|
|
64
|
+
))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function renderTerraform({ manifest, environment }) {
|
|
68
|
+
const config = manifest.providers.digitalocean || {}
|
|
69
|
+
const resources = renderGenericResources(config.resources)
|
|
70
|
+
const mainBlocks = [
|
|
71
|
+
renderLocals(manifest, environment),
|
|
72
|
+
block('provider', ['digitalocean'], {}),
|
|
73
|
+
...objectStorageBlocks(manifest, environment, config),
|
|
74
|
+
...databaseBlocks(manifest, environment, config),
|
|
75
|
+
...dropletBlocks(manifest, environment, config, 'sandboxes', 'sandbox'),
|
|
76
|
+
...dropletBlocks(manifest, environment, config, 'clusters', 'cluster'),
|
|
77
|
+
resources,
|
|
78
|
+
].filter(Boolean)
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
'versions.tf': `${renderRequiredProvider('digitalocean', 'digitalocean/digitalocean', config.version || '>= 2.0.0')}\n`,
|
|
82
|
+
'main.tf': `${mainBlocks.join('\n\n')}\n`,
|
|
83
|
+
...(outputBlocks(manifest).length > 0 ? { 'outputs.tf': `${outputBlocks(manifest).join('\n\n')}\n` } : {}),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
block,
|
|
3
|
+
raw,
|
|
4
|
+
renderGenericResources,
|
|
5
|
+
renderLocals,
|
|
6
|
+
renderRequiredProvider,
|
|
7
|
+
sanitizeName,
|
|
8
|
+
} from '../../core/hcl.mjs'
|
|
9
|
+
|
|
10
|
+
function providerBody(config) {
|
|
11
|
+
return {
|
|
12
|
+
team: config.team || config.teamId || config.teamSlug,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function appProject(app) {
|
|
17
|
+
return block('resource', ['vercel_project', sanitizeName(app.key)], {
|
|
18
|
+
name: app.name,
|
|
19
|
+
framework: app.framework,
|
|
20
|
+
root_directory: app.rootDirectory,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function appDomains(app) {
|
|
25
|
+
const domains = Array.isArray(app.domains) ? app.domains : []
|
|
26
|
+
return domains.map((domain) => domainResource(domain, app.key))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function domainResource(domain, appKey) {
|
|
30
|
+
const name = typeof domain === 'string' ? domain : domain.name
|
|
31
|
+
const targetApp = appKey || domain.app || domain.project
|
|
32
|
+
if (!name || !targetApp) return ''
|
|
33
|
+
|
|
34
|
+
const resourceName = sanitizeName(`${targetApp}_${name}`)
|
|
35
|
+
return block('resource', ['vercel_project_domain', resourceName], {
|
|
36
|
+
project_id: raw(`vercel_project.${sanitizeName(targetApp)}.id`),
|
|
37
|
+
domain: name,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderTerraform({ manifest, environment }) {
|
|
42
|
+
const config = manifest.providers.vercel || {}
|
|
43
|
+
const appBlocks = manifest.apps.map(appProject)
|
|
44
|
+
const appDomainBlocks = manifest.apps.flatMap(appDomains)
|
|
45
|
+
const topLevelDomainBlocks = manifest.domains.map((domain) => domainResource(domain))
|
|
46
|
+
const genericResources = renderGenericResources(config.resources)
|
|
47
|
+
const mainBlocks = [
|
|
48
|
+
renderLocals(manifest, environment),
|
|
49
|
+
block('provider', ['vercel'], providerBody(config)),
|
|
50
|
+
...appBlocks,
|
|
51
|
+
...appDomainBlocks,
|
|
52
|
+
...topLevelDomainBlocks,
|
|
53
|
+
genericResources,
|
|
54
|
+
].filter(Boolean)
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
'versions.tf': `${renderRequiredProvider('vercel', 'vercel/vercel', '>= 1.0.0')}\n`,
|
|
58
|
+
'main.tf': `${mainBlocks.join('\n\n')}\n`,
|
|
59
|
+
}
|
|
60
|
+
}
|