init
This commit is contained in:
4
packages/resource-cloudflare/.gitignore
vendored
Normal file
4
packages/resource-cloudflare/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/coverage/
|
||||
/.env
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: playground.homelab.olsen.cloud/v1
|
||||
kind: CloudflareAccount
|
||||
metadata:
|
||||
name: main
|
||||
spec:
|
||||
token:
|
||||
secret: cloudflare
|
||||
namespace: homelab
|
||||
key: token
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: playground.homelab.olsen.cloud/v1
|
||||
kind: CloudflareDnsRecord
|
||||
metadata:
|
||||
name: test
|
||||
spec:
|
||||
account: main
|
||||
domain: olsen.cloud
|
||||
subdomain: testing1
|
||||
type: CNAME
|
||||
value: hello
|
||||
33
packages/resource-cloudflare/package.json
Normal file
33
packages/resource-cloudflare/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "module",
|
||||
"main": "dist/exports.js",
|
||||
"scripts": {
|
||||
"dev": "tsx --watch src/start.ts",
|
||||
"build": "tsc --build",
|
||||
"test:unit": "vitest --run --passWithNoTests",
|
||||
"test": "pnpm run \"/^test:/\""
|
||||
},
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/exports.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@morten-olsen/box-configs": "workspace:*",
|
||||
"@morten-olsen/box-tests": "workspace:*",
|
||||
"@types/node": "24.9.1",
|
||||
"@vitest/coverage-v8": "4.0.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@morten-olsen/box-k8s": "workspace:*",
|
||||
"@morten-olsen/box-utils": "workspace:*",
|
||||
"cloudflare": "^5.2.0"
|
||||
},
|
||||
"name": "@morten-olsen/box-resource-cloudflare",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
0
packages/resource-cloudflare/src/exports.ts
Normal file
0
packages/resource-cloudflare/src/exports.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from '@morten-olsen/box-k8s';
|
||||
|
||||
const cloudflareAccountSchema = z.object({
|
||||
token: z.object({
|
||||
secret: z.string(),
|
||||
namespace: z.string(),
|
||||
key: z.string(),
|
||||
}),
|
||||
allowedNamespaces: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export { cloudflareAccountSchema };
|
||||
@@ -0,0 +1,48 @@
|
||||
import { CustomResource, NotReadyError, ResourceReference, Secret, type CustomResourceOptions } from "@morten-olsen/box-k8s";
|
||||
import { cloudflareAccountSchema } from "./account.schemas.js";
|
||||
import { API_VERSION } from "@morten-olsen/box-utils/consts";
|
||||
|
||||
class CloudflareAccountResource extends CustomResource<typeof cloudflareAccountSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = "CloudflareAccount";
|
||||
public static readonly spec = cloudflareAccountSchema;
|
||||
public static readonly scope = "Cluster";
|
||||
public static readonly dependsOn = [Secret];
|
||||
|
||||
#secret: ResourceReference<typeof Secret>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof cloudflareAccountSchema>) {
|
||||
super(options);
|
||||
this.#secret = new ResourceReference(this.#getSecret());
|
||||
this.#secret.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
public get token() {
|
||||
const spec = this.spec;
|
||||
if (!spec) {
|
||||
return;
|
||||
}
|
||||
return this.#secret.current?.value?.[spec.token.key];
|
||||
}
|
||||
|
||||
#getSecret = () => {
|
||||
const spec = this.spec;
|
||||
if (!spec) {
|
||||
return;
|
||||
}
|
||||
return this.resources.get(
|
||||
Secret,
|
||||
spec.token.secret,
|
||||
spec.token.namespace,
|
||||
)
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
this.#secret.current = this.#getSecret();
|
||||
if (!this.token) {
|
||||
throw new NotReadyError('Token not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { CloudflareAccountResource };
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from '@morten-olsen/box-k8s';
|
||||
|
||||
const cloudflareDnsRecordSchema = z.object({
|
||||
account: z.string(),
|
||||
domain: z.string(),
|
||||
subdomain: z.string().optional(),
|
||||
type: z.enum(['A', 'CNAME', 'MX']),
|
||||
proxy: z.boolean().optional(),
|
||||
value: z.string(),
|
||||
ttl: z.number().optional(),
|
||||
});
|
||||
|
||||
export { cloudflareDnsRecordSchema };
|
||||
@@ -0,0 +1,40 @@
|
||||
import { CustomResource, NotReadyError } from '@morten-olsen/box-k8s';
|
||||
|
||||
import { CloudflareService } from '../../services/cloudflare/cloudflare.js';
|
||||
|
||||
import { cloudflareDnsRecordSchema } from './dns-record.schemas.js';
|
||||
import { API_VERSION } from '@morten-olsen/box-utils/consts';
|
||||
import { CloudflareAccountResource } from '../account/account.js';
|
||||
|
||||
class CloudflareDnsRecordResource extends CustomResource<typeof cloudflareDnsRecordSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = "CloudflareDnsRecord";
|
||||
public static readonly spec = cloudflareDnsRecordSchema;
|
||||
public static readonly scope = "Namespaced";
|
||||
public static readonly dependsOn = [CloudflareAccountResource];
|
||||
|
||||
public get dnsId() {
|
||||
return `homelab|${this.namespace}|${this.name}`
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.spec) {
|
||||
throw new NotReadyError('Missing spec');
|
||||
}
|
||||
const service = this.services.get(CloudflareService);
|
||||
const { getDnsRecord, ensrureDnsRecord } = service.getAccount(this.spec.account);
|
||||
await ensrureDnsRecord(this.dnsId, this.spec);
|
||||
};
|
||||
|
||||
public destroy = async () => {
|
||||
|
||||
if (!this.spec) {
|
||||
throw new NotReadyError('Missing spec');
|
||||
}
|
||||
const service = this.services.get(CloudflareService);
|
||||
const { removeDnsRecord } = service.getAccount(this.spec.account);
|
||||
await removeDnsRecord(this.dnsId, this.spec.domain);
|
||||
}
|
||||
}
|
||||
|
||||
export { CloudflareDnsRecordResource, cloudflareDnsRecordSchema };
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Services } from "@morten-olsen/box-utils/services";
|
||||
import type { CloudflareAccountResource } from "../../resources/account/account.js"
|
||||
import API from 'cloudflare';
|
||||
import type { Zone } from "cloudflare/resources/zones/zones.mjs";
|
||||
import type { z } from "@morten-olsen/box-k8s";
|
||||
import type { cloudflareDnsRecordSchema } from "../../resources/dns-record/dns-record.schemas.js";
|
||||
|
||||
type CloudflareAccountOptions = {
|
||||
services: Services;
|
||||
resource: CloudflareAccountResource;
|
||||
}
|
||||
|
||||
class CloudflareAccount {
|
||||
#options: CloudflareAccountOptions;
|
||||
#api?: API;
|
||||
#zones: Map<string, Promise<Zone | undefined>>
|
||||
|
||||
constructor(options: CloudflareAccountOptions) {
|
||||
this.#zones = new Map();
|
||||
this.#options = options;
|
||||
this.#api = new API({
|
||||
apiToken: this.token,
|
||||
});
|
||||
}
|
||||
|
||||
public get token() {
|
||||
return this.#options.resource.token;
|
||||
}
|
||||
|
||||
public get api() {
|
||||
if (!this.#api) {
|
||||
this.#api = new API({
|
||||
apiToken: this.token,
|
||||
})
|
||||
}
|
||||
return this.#api;
|
||||
}
|
||||
|
||||
#getZone = async (name: string) => {
|
||||
const zones = await this.api.zones.list({
|
||||
name,
|
||||
})
|
||||
const [zone] = zones.result;
|
||||
return zone;
|
||||
}
|
||||
|
||||
public getZone = async (name: string) => {
|
||||
if (!this.#zones.has(name)) {
|
||||
this.#zones.set(name, this.#getZone(name));
|
||||
}
|
||||
const current = this.#zones.get(name);
|
||||
return await current;
|
||||
}
|
||||
|
||||
public getDnsRecord = async (id: string, domain: string) => {
|
||||
const zone = await this.getZone(domain);
|
||||
|
||||
if (!zone) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dnsRecords = await this.api.dns.records.list({
|
||||
zone_id: zone.id,
|
||||
comment: {
|
||||
exact: id,
|
||||
},
|
||||
})
|
||||
const [dnsRecord] = dnsRecords.result;
|
||||
|
||||
return dnsRecord;
|
||||
}
|
||||
|
||||
public removeDnsRecord = async (id: string, domain: string) => {
|
||||
const zone = await this.getZone(domain);
|
||||
if (!zone) {
|
||||
return;
|
||||
}
|
||||
const record = await this.getDnsRecord(id, domain);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.api.dns.records.delete(record.id, {
|
||||
zone_id: zone.id,
|
||||
});
|
||||
}
|
||||
|
||||
public ensrureDnsRecord = async (id: string, options: z.infer<typeof cloudflareDnsRecordSchema>) => {
|
||||
const { domain, subdomain, value, type, ttl = 1, proxy } = options;
|
||||
const zone = await this.getZone(options.domain);
|
||||
if (!zone) {
|
||||
throw new Error('Zone not found');
|
||||
}
|
||||
const current = await this.getDnsRecord(id, domain);
|
||||
|
||||
if (!current) {
|
||||
await this.api.dns.records.create({
|
||||
zone_id: zone.id,
|
||||
type,
|
||||
name: subdomain ? `${subdomain}.${domain}` : domain,
|
||||
content: value,
|
||||
comment: id,
|
||||
ttl,
|
||||
proxied: proxy,
|
||||
})
|
||||
} else {
|
||||
|
||||
await this.api.dns.records.update(current.id, {
|
||||
zone_id: zone.id,
|
||||
type,
|
||||
name: subdomain ? `${subdomain}.${domain}` : domain,
|
||||
content: value,
|
||||
comment: id,
|
||||
ttl,
|
||||
proxied: proxy,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { CloudflareAccount };
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Services } from "@morten-olsen/box-utils/services";
|
||||
import { CloudflareAccount } from "./cloudflare.account.js";
|
||||
import { ResourceService } from "@morten-olsen/box-k8s";
|
||||
import { CloudflareAccountResource } from "../../resources/account/account.js";
|
||||
|
||||
class CloudflareService {
|
||||
#services: Services;
|
||||
#instances: Map<string, CloudflareAccount>;
|
||||
|
||||
constructor(services: Services) {
|
||||
this.#services = services;
|
||||
this.#instances = new Map();
|
||||
}
|
||||
|
||||
public getAccount = (name: string) => {
|
||||
if (!this.#instances.has(name)) {
|
||||
const resourceService = this.#services.get(ResourceService);
|
||||
const resource = resourceService.get(CloudflareAccountResource, name);
|
||||
this.#instances.set(name, new CloudflareAccount({
|
||||
resource,
|
||||
services: this.#services,
|
||||
}))
|
||||
}
|
||||
const current = this.#instances.get(name);
|
||||
if (!current) {
|
||||
throw new Error('Could not get cloudflare account');
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
export { CloudflareService };
|
||||
13
packages/resource-cloudflare/src/start.ts
Normal file
13
packages/resource-cloudflare/src/start.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { K8sOperator } from '@morten-olsen/box-k8s';
|
||||
import { CloudflareAccountResource } from './resources/account/account.js';
|
||||
import { CloudflareDnsRecordResource } from './resources/dns-record/dns-record.js';
|
||||
|
||||
const operator = new K8sOperator();
|
||||
await operator.resources.install(
|
||||
CloudflareAccountResource,
|
||||
CloudflareDnsRecordResource,
|
||||
)
|
||||
await operator.resources.register(
|
||||
CloudflareAccountResource,
|
||||
CloudflareDnsRecordResource,
|
||||
)
|
||||
9
packages/resource-cloudflare/tsconfig.json
Normal file
9
packages/resource-cloudflare/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"extends": "@morten-olsen/box-configs/tsconfig.json"
|
||||
}
|
||||
12
packages/resource-cloudflare/vitest.config.ts
Normal file
12
packages/resource-cloudflare/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { getAliases } from '@morten-olsen/box-tests/vitest';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineConfig(async () => {
|
||||
const aliases = await getAliases();
|
||||
return {
|
||||
resolve: {
|
||||
alias: aliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user