This commit is contained in:
Morten Olsen
2025-10-23 20:31:15 +02:00
parent b851dc3006
commit 78995406ca
46 changed files with 707 additions and 119 deletions

View 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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View 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,
)