Initial commit: Cloudflare DNS management with Pulumi
This commit is contained in:
commit
d8d2f39c99
17 changed files with 3748 additions and 0 deletions
39
.github/workflows/ci.yml
vendored
Normal file
39
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup Pulumi
|
||||
uses: pulumi/actions@v6
|
||||
with:
|
||||
pulumi-version: '3.214.1'
|
||||
|
||||
- name: Install Pulumi packages
|
||||
run: pulumi install
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
56
.github/workflows/deploy.yml
vendored
Normal file
56
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PULUMI_VERSION: "^3"
|
||||
|
||||
jobs:
|
||||
deploy-production:
|
||||
name: Deploy to Production
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup Pulumi
|
||||
uses: pulumi/actions@v6
|
||||
with:
|
||||
pulumi-version: ${{ env.PULUMI_VERSION }}
|
||||
|
||||
- name: Install Pulumi packages
|
||||
run: pulumi install
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
credentials_json: ${{ secrets.GCP_PROD_SERVICE_ACCOUNT_KEY }}
|
||||
|
||||
- name: Deploy to Production
|
||||
env:
|
||||
PULUMI_PASSPHRASE: ${{ secrets.PULUMI_PROD_PASSPHRASE }}
|
||||
run: |
|
||||
echo "$PULUMI_PASSPHRASE" > passphrase.prod.txt
|
||||
make up
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
node_modules/
|
||||
bin/
|
||||
*.log
|
||||
|
||||
# Pulumi
|
||||
.pulumi/
|
||||
Pulumi.*.yaml.bak
|
||||
|
||||
# Secrets
|
||||
passphrase.prod.txt
|
||||
sa-key.json
|
||||
24
.pre-commit-config.yaml
Normal file
24
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-merge-conflict
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: typescript-check
|
||||
name: TypeScript type check
|
||||
entry: npm run build
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files: \.(ts|tsx)$
|
||||
|
||||
- id: prettier
|
||||
name: Prettier formatting
|
||||
entry: npx prettier --write --ignore-unknown
|
||||
language: system
|
||||
types: [text]
|
||||
files: \.(ts|tsx|js|jsx|json|md)$
|
||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
sdks/
|
||||
*.yaml
|
||||
*.yml
|
||||
package-lock.json
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
16
Makefile
Normal file
16
Makefile
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
.PHONY: help login preview up
|
||||
|
||||
# Default target
|
||||
help: ## Show this help message
|
||||
@echo "Available targets:"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}'
|
||||
|
||||
# Production stack commands
|
||||
login: ## Login to Pulumi backend (GCS)
|
||||
pulumi login gs://mcp-dns-prod-pulumi-state
|
||||
|
||||
preview: login ## Preview infrastructure changes
|
||||
PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt pulumi preview --stack prod
|
||||
|
||||
up: login ## Deploy infrastructure
|
||||
PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt pulumi up --yes --stack prod
|
||||
5
Pulumi.prod.yaml
Normal file
5
Pulumi.prod.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
config:
|
||||
cloudflare:apiToken:
|
||||
secure: v1:Gf79eQjoonySoEro:69bihM9dbe4oN5K9FR2ZaWz8TgQw63f3e+E4pnh+EEkOS0fyUsgglcPWWXp+HEqt+cZmjC/0qig=
|
||||
mcp-dns:cloudflareAccountId: c39d6379ccfdb20a3627eeddffa80ec9
|
||||
encryptionsalt: v1:X08GNi1Y/Z4=:v1:FGn4pn2xckU75bKC:xiS5tgANyY/gVHFRUEQC/b5tZK2zHA==
|
||||
3
Pulumi.yaml
Normal file
3
Pulumi.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
name: mcp-dns
|
||||
runtime: nodejs
|
||||
description: Infrastructure as Code for MCP DNS management
|
||||
81
README.md
Normal file
81
README.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# MCP DNS Management
|
||||
|
||||
Infrastructure as Code for managing MCP Cloudflare DNS records using Pulumi.
|
||||
|
||||
DNS records are defined in [`src/config/records.ts`](src/config/records.ts), keyed by domain name.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Production Deployment (Automated)
|
||||
|
||||
**Note:** Production deployment is automatically handled by GitHub Actions. All merges to the `main` branch trigger an automatic deployment via [the configured GitHub Actions workflow](.github/workflows/deploy.yml).
|
||||
|
||||
### Manual Deployment
|
||||
|
||||
Pre-requisites:
|
||||
|
||||
- [Pulumi CLI installed](https://www.pulumi.com/docs/iac/download-install/)
|
||||
- [Google Cloud SDK installed](https://cloud.google.com/sdk/docs/install)
|
||||
- Access to GCP project and GCS bucket
|
||||
- Required credentials and secrets
|
||||
|
||||
1. Authenticate with GCP: `gcloud auth application-default login`
|
||||
2. Get the passphrase file `passphrase.prod.txt` from the maintainers
|
||||
3. Preview changes: `make preview`
|
||||
4. Deploy changes: `make up`
|
||||
|
||||
## Initial Setup
|
||||
|
||||
If setting up this infrastructure for the first time:
|
||||
|
||||
### 1. Create GCS Bucket for Pulumi State
|
||||
|
||||
```bash
|
||||
gcloud projects create mcp-dns-prod
|
||||
gcloud config set project mcp-dns-prod
|
||||
gcloud services enable storage.googleapis.com
|
||||
|
||||
# Create service account for CI/CD
|
||||
gcloud iam service-accounts create pulumi-svc \
|
||||
--display-name="Pulumi Service Account"
|
||||
|
||||
gcloud projects add-iam-policy-binding mcp-dns-prod \
|
||||
--member="serviceAccount:pulumi-svc@mcp-dns-prod.iam.gserviceaccount.com" \
|
||||
--role="roles/storage.admin"
|
||||
|
||||
gcloud iam service-accounts keys create sa-key.json \
|
||||
--iam-account=pulumi-svc@mcp-dns-prod.iam.gserviceaccount.com
|
||||
|
||||
# Create bucket
|
||||
gsutil mb gs://mcp-dns-prod-pulumi-state
|
||||
```
|
||||
|
||||
### 2. Create Cloudflare API Token
|
||||
|
||||
1. Go to [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens)
|
||||
2. Create a token with **Zone:DNS:Edit** permission for the zones you want to manage
|
||||
3. Note the token value
|
||||
|
||||
### 3. Initialize Pulumi Stack
|
||||
|
||||
```bash
|
||||
# Login to Pulumi backend
|
||||
pulumi login gs://mcp-dns-prod-pulumi-state
|
||||
|
||||
# Create production stack
|
||||
export PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt
|
||||
pulumi stack init prod
|
||||
|
||||
# Configure application secrets in Pulumi
|
||||
pulumi config set --secret cloudflare:apiToken "your-api-token"
|
||||
pulumi config set cloudflare:accountId "your-account-id"
|
||||
```
|
||||
|
||||
You can find your Cloudflare Account ID in the Cloudflare dashboard URL or in the right sidebar of any zone's overview page.
|
||||
|
||||
### 4. Configure GitHub Actions Secrets
|
||||
|
||||
Add the CI/CD secrets to GitHub Actions (repository settings → Secrets and variables → Actions):
|
||||
|
||||
- `GCP_PROD_SERVICE_ACCOUNT_KEY`: Content of `sa-key.json`
|
||||
- `PULUMI_PROD_PASSPHRASE`: The passphrase you set above
|
||||
3344
package-lock.json
generated
Normal file
3344
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
package.json
Normal file
21
package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@mcp/dns",
|
||||
"version": "1.0.0",
|
||||
"description": "Infrastructure as Code for MCP Cloudflare DNS management",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pulumi/cloudflare": "^6.12.0",
|
||||
"@pulumi/pulumi": "^3.214.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.18.6",
|
||||
"prettier": "^3.7.2",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
61
src/config/records.ts
Normal file
61
src/config/records.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { RecordType } from './utils';
|
||||
|
||||
export const DNS_RECORDS: Record<string, DnsRecordConfig[]> = {
|
||||
'modelcontextprotocol.io': [
|
||||
// Root domain
|
||||
{ subdomain: '@', type: 'A', content: '76.76.21.21', proxied: false },
|
||||
|
||||
// Spec subdomain
|
||||
{ subdomain: 'spec', type: 'A', content: '76.76.21.21', proxied: false },
|
||||
|
||||
// Registry
|
||||
{ subdomain: 'registry', type: 'CNAME', content: 'prod.registry.modelcontextprotocol.io', proxied: false },
|
||||
{ subdomain: 'prod.registry', type: 'A', content: '34.61.200.254', proxied: false },
|
||||
{ subdomain: 'staging.registry', type: 'A', content: '35.222.36.75', proxied: false },
|
||||
{ subdomain: 'grafana.prod.registry', type: 'A', content: '34.61.200.254', proxied: false },
|
||||
{ subdomain: 'grafana.staging.registry', type: 'A', content: '35.222.36.75', proxied: false },
|
||||
|
||||
// GitHub Pages sites
|
||||
{ subdomain: 'blog', type: 'CNAME', content: 'modelcontextprotocol.github.io', proxied: false },
|
||||
{ subdomain: 'example-client', type: 'CNAME', content: 'modelcontextprotocol.github.io', proxied: false },
|
||||
{ subdomain: 'static', type: 'CNAME', content: 'modelcontextprotocol.github.io', proxied: false },
|
||||
|
||||
// Other subdomains
|
||||
{ subdomain: 'example-server', type: 'CNAME', content: 'ghs.googlehosted.com', proxied: true },
|
||||
{ subdomain: 'meet', type: 'CNAME', content: 'mcp.meetable.org', proxied: false },
|
||||
|
||||
// MX record for Google Workspace
|
||||
{ subdomain: '@', type: 'MX', content: 'smtp.google.com', priority: 1, comment: 'ITOPS-9980' },
|
||||
|
||||
// SPF record
|
||||
{ subdomain: '@', type: 'TXT', content: 'v=spf1 include:_spf.google.com ~all' },
|
||||
|
||||
// DKIM for Google Workspace
|
||||
{
|
||||
subdomain: 'google._domainkey',
|
||||
type: 'TXT',
|
||||
content:
|
||||
'v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtwBNpASgdq3T9tXSFRh3tu9s6bLVBeGtug5Tv5f5D9K2G8n3Ov7KgR8IEyIgWaVuFf+z82NUBtXlDfKRxRu6s9ND+SqPkgVey2IVx4VuHHUJMAC1f4Wst7ZAUmyeo9lf6PpPb7xzr4nSVbN9jbIFDAajsCe+M9TYdkIU9PXdraYpIVc6b+vE7EaiaCdXNWHzIVjo4wFBz4UsNKG1UsO53GZNyXfnxFFPQppaNYEXOfblYtxgW4BhKpBQhT9fC07EKyoUApe2AC8rcX83HBOKKvISR6TbMruv5p82lz1veIcpixRkZfIFDlxMF66yzFs5qdfY8ztRHbri4kw4DVygmwIDAQAB',
|
||||
comment: 'ITOPS-9980',
|
||||
},
|
||||
|
||||
// Google site verifications
|
||||
{ subdomain: '@', type: 'TXT', content: 'google-site-verification=0jJ1JQ6pdBjRe2r6UCzi2RJbsjvglJUmekPb5rpUQXQ' },
|
||||
{ subdomain: '@', type: 'TXT', content: 'google-site-verification=UI0Tjq-ecUgNu3kFATkW87qcabX6kTljsbYjms2-FdQ' },
|
||||
|
||||
// Other TXT verifications
|
||||
{ subdomain: '@', type: 'TXT', content: 'czyymtp25a' },
|
||||
{ subdomain: '_gh-modelcontextprotocol-o', type: 'TXT', content: '8f29e697fc' },
|
||||
{ subdomain: '_github-pages-challenge-modelcontextprotocol.blog', type: 'TXT', content: '1d4b431f4dc23d532fd33da4596bcf' },
|
||||
],
|
||||
};
|
||||
|
||||
interface DnsRecordConfig {
|
||||
subdomain: string;
|
||||
type: RecordType;
|
||||
content: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
priority?: number;
|
||||
comment?: string;
|
||||
}
|
||||
20
src/config/utils.ts
Normal file
20
src/config/utils.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export type RecordType =
|
||||
| 'A'
|
||||
| 'AAAA'
|
||||
| 'CAA'
|
||||
| 'CNAME'
|
||||
| 'DNSKEY'
|
||||
| 'DS'
|
||||
| 'HTTPS'
|
||||
| 'LOC'
|
||||
| 'MX'
|
||||
| 'NAPTR'
|
||||
| 'NS'
|
||||
| 'PTR'
|
||||
| 'SMIMEA'
|
||||
| 'SRV'
|
||||
| 'SSHFP'
|
||||
| 'SVCB'
|
||||
| 'TLSA'
|
||||
| 'TXT'
|
||||
| 'URI';
|
||||
38
src/dns.ts
Normal file
38
src/dns.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import * as pulumi from '@pulumi/pulumi';
|
||||
import * as cloudflare from '@pulumi/cloudflare';
|
||||
import { DNS_RECORDS } from './config/records';
|
||||
|
||||
const config = new pulumi.Config();
|
||||
const accountId = config.require('cloudflareAccountId');
|
||||
|
||||
for (const [domain, records] of Object.entries(DNS_RECORDS)) {
|
||||
const zone = cloudflare.getZoneOutput({
|
||||
filter: {
|
||||
name: domain,
|
||||
account: { id: accountId },
|
||||
},
|
||||
});
|
||||
|
||||
// Track counts for generating unique names when same subdomain+type appears multiple times
|
||||
const nameCounts: Record<string, number> = {};
|
||||
|
||||
for (const record of records) {
|
||||
const baseKey = `${record.subdomain}-${record.type}`;
|
||||
const count = nameCounts[baseKey] ?? 0;
|
||||
nameCounts[baseKey] = count + 1;
|
||||
|
||||
// e.g., "modelcontextprotocol.io-@-A" or "modelcontextprotocol.io-@-TXT-2" for duplicates
|
||||
const resourceName = count === 0 ? `${domain}-${baseKey}` : `${domain}-${baseKey}-${count + 1}`;
|
||||
|
||||
new cloudflare.DnsRecord(resourceName, {
|
||||
zoneId: zone.id,
|
||||
name: record.subdomain,
|
||||
type: record.type,
|
||||
content: record.content,
|
||||
ttl: record.ttl ?? 1,
|
||||
proxied: record.proxied ?? false,
|
||||
priority: record.priority,
|
||||
comment: record.comment,
|
||||
});
|
||||
}
|
||||
}
|
||||
1
src/index.ts
Normal file
1
src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './dns';
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"moduleResolution": "node",
|
||||
"noEmit": true,
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Reference in a new issue