mirror of
https://github.com/morten-olsen/homelab-operator.git
synced 2026-02-08 01:36:28 +01:00
stuff
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { CloudflareTunnel } from '#resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts';
|
||||
import { ResourceService } from '#services/resources/resources.ts';
|
||||
import type { Services } from '../utils/service.ts';
|
||||
|
||||
import { NamespaceService } from './namespaces/namespaces.ts';
|
||||
@@ -22,10 +24,18 @@ class BootstrapService {
|
||||
return this.#services.get(ReleaseService);
|
||||
}
|
||||
|
||||
public get cloudflareTunnel() {
|
||||
const resourceService = this.#services.get(ResourceService);
|
||||
return resourceService.get(CloudflareTunnel, 'cloudflare-tunnel', this.namespaces.homelab.name);
|
||||
}
|
||||
|
||||
public ensure = async () => {
|
||||
await this.namespaces.ensure();
|
||||
await this.repos.ensure();
|
||||
await this.releases.ensure();
|
||||
await this.cloudflareTunnel.ensure({
|
||||
spec: {},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,16 +8,19 @@ class RepoService {
|
||||
#jetstack: HelmRepo;
|
||||
#istio: HelmRepo;
|
||||
#authentik: HelmRepo;
|
||||
#cloudflare: HelmRepo;
|
||||
|
||||
constructor(services: Services) {
|
||||
const resourceService = services.get(ResourceService);
|
||||
this.#jetstack = resourceService.get(HelmRepo, 'jetstack', NAMESPACE);
|
||||
this.#istio = resourceService.get(HelmRepo, 'istio', NAMESPACE);
|
||||
this.#authentik = resourceService.get(HelmRepo, 'authentik', NAMESPACE);
|
||||
this.#cloudflare = resourceService.get(HelmRepo, 'cloudflare', NAMESPACE);
|
||||
|
||||
this.#jetstack.on('changed', this.ensure);
|
||||
this.#istio.on('changed', this.ensure);
|
||||
this.#authentik.on('changed', this.ensure);
|
||||
this.#cloudflare.on('changed', this.ensure);
|
||||
}
|
||||
|
||||
public get jetstack() {
|
||||
@@ -32,6 +35,10 @@ class RepoService {
|
||||
return this.#authentik;
|
||||
}
|
||||
|
||||
public get cloudflare() {
|
||||
return this.#cloudflare;
|
||||
}
|
||||
|
||||
public ensure = async () => {
|
||||
await this.#jetstack.set({
|
||||
url: 'https://charts.jetstack.io',
|
||||
@@ -44,6 +51,10 @@ class RepoService {
|
||||
await this.#authentik.set({
|
||||
url: 'https://charts.goauthentik.io',
|
||||
});
|
||||
|
||||
await this.#cloudflare.set({
|
||||
url: 'https://cloudflare.github.io/helm-charts',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import type { V1Secret } from '@kubernetes/client-node';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceOptions,
|
||||
type SubresourceResult,
|
||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { ResourceReference } from '../../services/resources/resources.ref.ts';
|
||||
import { ResourceService, type Resource } from '../../services/resources/resources.ts';
|
||||
import { getWithNamespace } from '../../utils/naming.ts';
|
||||
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
|
||||
import { CONTROLLED_LABEL } from '../../utils/consts.ts';
|
||||
import { isDeepSubset } from '../../utils/objects.ts';
|
||||
import { AuthentikService } from '../../services/authentik/authentik.service.ts';
|
||||
import { authentikServerSecretSchema } from '../authentik-server/authentik-server.schemas.ts';
|
||||
|
||||
import { authentikClientSecretSchema, type authentikClientSpecSchema } from './authentik-client.schemas.ts';
|
||||
|
||||
class AuthentikClientController extends CustomResource<typeof authentikClientSpecSchema> {
|
||||
#serverSecret: ResourceReference<V1Secret>;
|
||||
#clientSecretResource: Resource<V1Secret>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof authentikClientSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
|
||||
this.#serverSecret = new ResourceReference();
|
||||
this.#clientSecretResource = resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: `authentik-client-${this.name}`,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
this.#updateResouces();
|
||||
|
||||
this.#serverSecret.on('changed', this.queueReconcile);
|
||||
this.#clientSecretResource.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
#updateResouces = () => {
|
||||
const serverSecretNames = getWithNamespace(`${this.spec.server}-server`, this.namespace);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#serverSecret.current = resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: serverSecretNames.name,
|
||||
namespace: serverSecretNames.namespace,
|
||||
});
|
||||
};
|
||||
|
||||
#reconcileClientSecret = async (): Promise<SubresourceResult> => {
|
||||
const serverSecret = this.#serverSecret.current;
|
||||
if (!serverSecret?.exists || !serverSecret.data) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
message: 'Server or server secret not found',
|
||||
};
|
||||
}
|
||||
const serverSecretData = authentikServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
|
||||
if (!serverSecretData.success || !serverSecretData.data) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
message: 'Server secret not found',
|
||||
};
|
||||
}
|
||||
const url = serverSecretData.data.url;
|
||||
const appName = this.name;
|
||||
const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(this.#clientSecretResource.data));
|
||||
|
||||
const expectedValues: z.infer<typeof authentikClientSecretSchema> = {
|
||||
clientId: this.name,
|
||||
clientSecret: clientSecretData.data?.clientSecret || crypto.randomUUID(),
|
||||
configuration: new URL(`/application/o/${appName}/.well-known/openid-configuration`, url).toString(),
|
||||
configurationIssuer: new URL(`/application/o/${appName}/`, url).toString(),
|
||||
authorization: new URL(`/application/o/${appName}/authorize/`, url).toString(),
|
||||
token: new URL(`/application/o/${appName}/token/`, url).toString(),
|
||||
userinfo: new URL(`/application/o/${appName}/userinfo/`, url).toString(),
|
||||
endSession: new URL(`/application/o/${appName}/end-session/`, url).toString(),
|
||||
jwks: new URL(`/application/o/${appName}/jwks/`, url).toString(),
|
||||
};
|
||||
if (!isDeepSubset(clientSecretData.data, expectedValues)) {
|
||||
await this.#clientSecretResource.patch({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
labels: {
|
||||
...CONTROLLED_LABEL,
|
||||
},
|
||||
},
|
||||
data: encodeSecret(expectedValues),
|
||||
});
|
||||
return {
|
||||
ready: false,
|
||||
syncing: true,
|
||||
message: 'UpdatingManifest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
#reconcileServer = async (): Promise<SubresourceResult> => {
|
||||
const serverSecret = this.#serverSecret.current;
|
||||
const clientSecret = this.#clientSecretResource;
|
||||
|
||||
if (!serverSecret?.exists || !serverSecret.data) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
message: 'Server secret not found',
|
||||
};
|
||||
}
|
||||
|
||||
const serverSecretData = authentikServerSecretSchema.safeParse(decodeSecret(serverSecret.data));
|
||||
if (!serverSecretData.success || !serverSecretData.data) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
message: 'Server secret not found',
|
||||
};
|
||||
}
|
||||
|
||||
const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(clientSecret.data));
|
||||
if (!clientSecretData.success || !clientSecretData.data) {
|
||||
return {
|
||||
ready: false,
|
||||
failed: true,
|
||||
message: 'Client secret not found',
|
||||
};
|
||||
}
|
||||
|
||||
const authentikService = this.services.get(AuthentikService);
|
||||
const authentikServer = authentikService.get({
|
||||
url: {
|
||||
internal: `http://${serverSecretData.data.host}`,
|
||||
external: serverSecretData.data.url,
|
||||
},
|
||||
token: serverSecretData.data.token,
|
||||
});
|
||||
|
||||
(await authentikServer).upsertClient({
|
||||
...this.spec,
|
||||
name: this.name,
|
||||
secret: clientSecretData.data.clientSecret,
|
||||
});
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
};
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
||||
return;
|
||||
}
|
||||
this.#updateResouces();
|
||||
await Promise.all([
|
||||
this.reconcileSubresource('Secret', this.#reconcileClientSecret),
|
||||
this.reconcileSubresource('Server', this.#reconcileServer),
|
||||
]);
|
||||
|
||||
const secretReady = this.conditions.get('Secret')?.status === 'True';
|
||||
const serverReady = this.conditions.get('Server')?.status === 'True';
|
||||
|
||||
await this.conditions.set('Ready', {
|
||||
status: secretReady && serverReady ? 'True' : 'False',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { AuthentikClientController };
|
||||
@@ -1,28 +0,0 @@
|
||||
import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
|
||||
import { z } from 'zod';
|
||||
|
||||
const authentikClientSpecSchema = z.object({
|
||||
server: z.string(),
|
||||
subMode: z.enum(SubModeEnum).optional(),
|
||||
clientType: z.enum(ClientTypeEnum).optional(),
|
||||
redirectUris: z.array(
|
||||
z.object({
|
||||
url: z.string(),
|
||||
matchingMode: z.enum(['strict', 'regex']),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const authentikClientSecretSchema = z.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string().optional(),
|
||||
configuration: z.string(),
|
||||
configurationIssuer: z.string(),
|
||||
authorization: z.string(),
|
||||
token: z.string(),
|
||||
userinfo: z.string(),
|
||||
endSession: z.string(),
|
||||
jwks: z.string(),
|
||||
});
|
||||
|
||||
export { authentikClientSpecSchema, authentikClientSecretSchema };
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { AuthentikClientController } from './authentik-client.controller.ts';
|
||||
import { authentikClientSpecSchema } from './authentik-client.schemas.ts';
|
||||
|
||||
const authentikClientDefinition = createCustomResourceDefinition({
|
||||
group: GROUP,
|
||||
version: 'v1',
|
||||
kind: 'AuthentikClient',
|
||||
names: {
|
||||
plural: 'authentikclients',
|
||||
singular: 'authentikclient',
|
||||
},
|
||||
create: (options) => new AuthentikClientController(options),
|
||||
spec: authentikClientSpecSchema,
|
||||
});
|
||||
|
||||
export { authentikClientDefinition };
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { V1Secret } from '@kubernetes/client-node';
|
||||
|
||||
import {
|
||||
CustomResource,
|
||||
type CustomResourceOptions,
|
||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
||||
import { Resource, ResourceService } from '../../services/resources/resources.ts';
|
||||
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
|
||||
import { isDeepSubset } from '../../utils/objects.ts';
|
||||
|
||||
import { generateSecrets } from './generate-secret.utils.ts';
|
||||
import { generateSecretSpecSchema } from './generate-secret.schemas.ts';
|
||||
|
||||
class GenerateSecretResource extends CustomResource<typeof generateSecretSpecSchema> {
|
||||
#secretResource: Resource<V1Secret>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof generateSecretSpecSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
|
||||
this.#secretResource = resourceService.get({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
name: this.name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
|
||||
this.#secretResource.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secrets = generateSecrets(this.spec);
|
||||
const current = decodeSecret(this.#secretResource.data) || {};
|
||||
|
||||
const expected = {
|
||||
...secrets,
|
||||
...current,
|
||||
};
|
||||
|
||||
if (!isDeepSubset(current, expected)) {
|
||||
this.#secretResource.patch({
|
||||
data: encodeSecret(expected),
|
||||
});
|
||||
this.conditions.set('SecretUpdated', {
|
||||
status: 'False',
|
||||
reason: 'SecretUpdated',
|
||||
});
|
||||
}
|
||||
|
||||
this.conditions.set('Ready', {
|
||||
status: 'True',
|
||||
reason: 'Ready',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { GenerateSecretResource };
|
||||
@@ -1,17 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const generateSecretFieldSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string().optional(),
|
||||
encoding: z.enum(['base64', 'base64url', 'hex', 'utf8', 'numeric']).optional(),
|
||||
length: z.number().optional(),
|
||||
});
|
||||
|
||||
const generateSecretSpecSchema = z.object({
|
||||
fields: z.array(generateSecretFieldSchema),
|
||||
});
|
||||
|
||||
type GenerateSecretField = z.infer<typeof generateSecretFieldSchema>;
|
||||
type GenerateSecretSpec = z.infer<typeof generateSecretSpecSchema>;
|
||||
|
||||
export { generateSecretSpecSchema, type GenerateSecretField, type GenerateSecretSpec };
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
||||
import { GROUP } from '../../utils/consts.ts';
|
||||
|
||||
import { GenerateSecretResource } from './generate-secret.resource.ts';
|
||||
import { generateSecretSpecSchema } from './generate-secret.schemas.ts';
|
||||
|
||||
const generateSecretDefinition = createCustomResourceDefinition({
|
||||
group: GROUP,
|
||||
version: 'v1',
|
||||
kind: 'GenerateSecret',
|
||||
names: {
|
||||
plural: 'generate-secrets',
|
||||
singular: 'generate-secret',
|
||||
},
|
||||
spec: generateSecretSpecSchema,
|
||||
create: (options) => new GenerateSecretResource(options),
|
||||
});
|
||||
|
||||
export { generateSecretDefinition };
|
||||
@@ -75,6 +75,26 @@ class AuthentikServer extends CustomResource<typeof specSchema> {
|
||||
this.#destinationRule.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
public get service() {
|
||||
return this.#service;
|
||||
}
|
||||
|
||||
public get secret() {
|
||||
return this.#secret;
|
||||
}
|
||||
|
||||
public get subdomain() {
|
||||
return this.spec?.subdomain || 'authentik';
|
||||
}
|
||||
|
||||
public get domain() {
|
||||
return `${this.subdomain}.${this.#environment.current?.spec?.domain}`;
|
||||
}
|
||||
|
||||
public get url() {
|
||||
return `https://${this.domain}`;
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.spec) {
|
||||
throw new NotReadyError('MissingSpec');
|
||||
@@ -240,7 +260,7 @@ class AuthentikServer extends CustomResource<typeof specSchema> {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
gateways: [`${gateway.namespace}/${gateway.name}`],
|
||||
gateways: [`${gateway.namespace}/${gateway.name}`, 'mesh'],
|
||||
hosts: [domain],
|
||||
http: [
|
||||
{
|
||||
|
||||
94
src/resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts
Normal file
94
src/resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
CustomResource,
|
||||
Resource,
|
||||
ResourceService,
|
||||
type CustomResourceOptions,
|
||||
} from '#services/resources/resources.ts';
|
||||
import z from 'zod';
|
||||
import { ExternalHttpService } from '../external-http-service.ts/external-http-service.ts';
|
||||
import { API_VERSION } from '#utils/consts.ts';
|
||||
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
|
||||
import { RepoService } from '#bootstrap/repos/repos.ts';
|
||||
import { Secret } from '#resources/core/secret/secret.ts';
|
||||
import { NotReadyError } from '#utils/errors.ts';
|
||||
import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts';
|
||||
|
||||
const specSchema = z.object({});
|
||||
|
||||
type SecretData = {
|
||||
account: string;
|
||||
tunnelName: string;
|
||||
tunnelId: string;
|
||||
secret: string;
|
||||
};
|
||||
class CloudflareTunnel extends CustomResource<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'CloudflareTunnel';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Cluster';
|
||||
|
||||
#helmRelease: HelmRelease;
|
||||
#secret: Secret<SecretData>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const namespaceService = this.services.get(NamespaceService);
|
||||
const namespace = namespaceService.homelab.name;
|
||||
resourceService.on('changed', this.#handleResourceChanged);
|
||||
|
||||
this.#helmRelease = resourceService.get(HelmRelease, this.name, namespace);
|
||||
this.#secret = resourceService.get(Secret<SecretData>, 'cloudflare', namespace);
|
||||
this.#secret.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
#handleResourceChanged = (resource: Resource<ExpectedAny>) => {
|
||||
if (resource instanceof CloudflareTunnel) {
|
||||
this.queueReconcile();
|
||||
}
|
||||
};
|
||||
|
||||
public reconcile = async () => {
|
||||
const secret = this.#secret.value;
|
||||
if (!secret) {
|
||||
throw new NotReadyError('MissingSecret', `Secret ${this.#secret.namespace}/${this.#secret.name} does not exist`);
|
||||
}
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const repoService = this.services.get(RepoService);
|
||||
const routes = resourceService.getAllOfKind(ExternalHttpService);
|
||||
const ingress = routes.map(({ rule }) => ({
|
||||
hostname: rule?.hostname,
|
||||
service: `http://${rule?.destination.host}:${rule?.destination.port.number}`,
|
||||
}));
|
||||
await this.#helmRelease.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
interval: '1h',
|
||||
values: {
|
||||
cloudflare: {
|
||||
account: secret.account,
|
||||
tunnelName: secret.tunnelName,
|
||||
tunnelId: secret.tunnelId,
|
||||
secret: secret.secret,
|
||||
ingress,
|
||||
},
|
||||
},
|
||||
chart: {
|
||||
spec: {
|
||||
chart: 'cloudflare-tunnel',
|
||||
sourceRef: {
|
||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||
kind: 'HelmRepository',
|
||||
name: repoService.cloudflare.name,
|
||||
namespace: repoService.cloudflare.namespace,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { CloudflareTunnel };
|
||||
@@ -12,6 +12,7 @@ import { StorageClass } from '#resources/core/storage-class/storage-class.ts';
|
||||
import { PROVISIONER } from '#resources/core/pvc/pvc.ts';
|
||||
import { Gateway } from '#resources/istio/gateway/gateway.ts';
|
||||
import { NotReadyError } from '#utils/errors.ts';
|
||||
import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts';
|
||||
|
||||
const specSchema = z.object({
|
||||
domain: z.string(),
|
||||
@@ -37,11 +38,12 @@ class Environment extends CustomResource<typeof specSchema> {
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const namespaceService = this.services.get(NamespaceService);
|
||||
|
||||
this.#namespace = resourceService.get(Namespace, this.name);
|
||||
this.#namespace.on('changed', this.queueReconcile);
|
||||
|
||||
this.#certificate = resourceService.get(Certificate, this.name, this.name);
|
||||
this.#certificate = resourceService.get(Certificate, this.name, namespaceService.homelab.name);
|
||||
this.#certificate.on('changed', this.queueReconcile);
|
||||
|
||||
this.#storageClass = resourceService.get(StorageClass, this.name);
|
||||
@@ -97,9 +99,6 @@ class Environment extends CustomResource<typeof specSchema> {
|
||||
},
|
||||
});
|
||||
await this.#certificate.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
secretName: `${this.name}-tls`,
|
||||
issuerRef: {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
|
||||
import { z } from 'zod';
|
||||
import { Environment } from '../environment/environment.ts';
|
||||
import { API_VERSION } from '#utils/consts.ts';
|
||||
|
||||
const specSchema = z.object({
|
||||
environment: z.string(),
|
||||
subdomain: z.string(),
|
||||
destination: z.object({
|
||||
host: z.string(),
|
||||
port: z.object({
|
||||
number: z.number(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
class ExternalHttpService extends CustomResource<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'ExternalHttpService';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Namespaced';
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
public get rule() {
|
||||
if (!this.spec) {
|
||||
return undefined;
|
||||
}
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
const env = resourceService.get(Environment, this.spec.environment);
|
||||
const hostname = `${this.spec.subdomain}.${env.spec?.domain}`;
|
||||
return {
|
||||
domain: env.spec?.domain,
|
||||
subdomain: this.spec.subdomain,
|
||||
hostname,
|
||||
destination: this.spec.destination,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { ExternalHttpService };
|
||||
47
src/resources/homelab/generate-secret/generate-secret.ts
Normal file
47
src/resources/homelab/generate-secret/generate-secret.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Secret } from '#resources/core/secret/secret.ts';
|
||||
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
|
||||
import { z } from 'zod';
|
||||
import { generateSecrets } from './generate-secret.utils.ts';
|
||||
import { API_VERSION } from '#utils/consts.ts';
|
||||
|
||||
const generateSecretFieldSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string().optional(),
|
||||
encoding: z.enum(['base64', 'base64url', 'hex', 'utf8', 'numeric']).optional(),
|
||||
length: z.number().optional(),
|
||||
});
|
||||
|
||||
const specSchema = z.object({
|
||||
fields: z.array(generateSecretFieldSchema),
|
||||
});
|
||||
|
||||
class GenerateSecret extends CustomResource<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'GenerateSecret';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Namespaced';
|
||||
|
||||
#secret: Secret;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
|
||||
this.#secret = resourceService.get(Secret, this.name, this.namespace);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
const secrets = generateSecrets(this.spec);
|
||||
const current = this.#secret.value;
|
||||
|
||||
const expected = {
|
||||
...secrets,
|
||||
...current,
|
||||
};
|
||||
|
||||
await this.#secret.ensure(expected);
|
||||
};
|
||||
}
|
||||
|
||||
export { GenerateSecret };
|
||||
@@ -5,13 +5,23 @@ import { PostgresDatabase } from './postgres-database/postgres-database.ts';
|
||||
import { AuthentikServer } from './authentik-server/authentik-server.ts';
|
||||
|
||||
import type { InstallableResourceClass } from '#services/resources/resources.ts';
|
||||
import { OIDCClient } from './oidc-client/oidc-client.ts';
|
||||
import { HttpService } from './http-service/http-service.ts';
|
||||
import { GenerateSecret } from './generate-secret/generate-secret.ts';
|
||||
import { ExternalHttpService } from './external-http-service.ts/external-http-service.ts';
|
||||
import { CloudflareTunnel } from './cloudflare-tunnel/cloudflare-tunnel.ts';
|
||||
|
||||
const homelab = {
|
||||
PostgresCluster,
|
||||
RedisServer,
|
||||
Environment,
|
||||
ExternalHttpService,
|
||||
CloudflareTunnel,
|
||||
AuthentikServer,
|
||||
PostgresDatabase,
|
||||
OIDCClient,
|
||||
HttpService,
|
||||
GenerateSecret,
|
||||
} satisfies Record<string, InstallableResourceClass<ExpectedAny>>;
|
||||
|
||||
export { homelab };
|
||||
|
||||
83
src/resources/homelab/http-service/http-service.ts
Normal file
83
src/resources/homelab/http-service/http-service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { VirtualService } from '#resources/istio/virtual-service/virtual-service.ts';
|
||||
import {
|
||||
CustomResource,
|
||||
ResourceReference,
|
||||
ResourceService,
|
||||
type CustomResourceOptions,
|
||||
} from '#services/resources/resources.ts';
|
||||
import { z } from 'zod';
|
||||
import { Environment } from '../environment/environment.ts';
|
||||
import { NotReadyError } from '#utils/errors.ts';
|
||||
import { API_VERSION } from '#utils/consts.ts';
|
||||
|
||||
const specSchema = z.object({
|
||||
environment: z.string(),
|
||||
subdomain: z.string(),
|
||||
destination: z.object({
|
||||
host: z.string(),
|
||||
port: z.object({
|
||||
number: z.number().optional(),
|
||||
name: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
class HttpService extends CustomResource<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'HttpService';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Namespaced';
|
||||
|
||||
#virtualService: VirtualService;
|
||||
#environment: ResourceReference<typeof Environment>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#virtualService = resourceService.get(VirtualService, this.name, this.namespace);
|
||||
this.#virtualService.on('changed', this.queueReconcile);
|
||||
|
||||
this.#environment = new ResourceReference();
|
||||
this.#environment.on('changed', this.queueReconcile);
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.spec) {
|
||||
throw new NotReadyError('MissingSpec');
|
||||
}
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
|
||||
this.#environment.current = resourceService.get(Environment, this.spec.environment);
|
||||
const env = this.#environment.current;
|
||||
if (!env.exists) {
|
||||
throw new NotReadyError('MissingEnvironment');
|
||||
}
|
||||
const gateway = env.gateway;
|
||||
const domain = env.spec?.domain;
|
||||
if (!domain) {
|
||||
throw new NotReadyError('MissingDomain');
|
||||
}
|
||||
const host = `${this.spec.subdomain}.${domain}`;
|
||||
this.#virtualService.ensure({
|
||||
metadata: {
|
||||
ownerReferences: [this.ref],
|
||||
},
|
||||
spec: {
|
||||
hosts: [host, 'mesh'],
|
||||
gateways: [`${gateway.namespace}/${gateway.name}`, 'mesh'],
|
||||
http: [
|
||||
{
|
||||
route: [
|
||||
{
|
||||
destination: this.spec.destination,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { HttpService };
|
||||
109
src/resources/homelab/oidc-client/oidc-client.ts
Normal file
109
src/resources/homelab/oidc-client/oidc-client.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
CustomResource,
|
||||
ResourceReference,
|
||||
ResourceService,
|
||||
type CustomResourceOptions,
|
||||
} from '#services/resources/resources.ts';
|
||||
import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
|
||||
import { z } from 'zod';
|
||||
import { Environment } from '../environment/environment.ts';
|
||||
import { API_VERSION } from '#utils/consts.ts';
|
||||
import { NotReadyError } from '#utils/errors.ts';
|
||||
import { Secret } from '#resources/core/secret/secret.ts';
|
||||
import { generateRandomHexPass } from '#utils/secrets.ts';
|
||||
import { AuthentikService } from '#services/authentik/authentik.service.ts';
|
||||
|
||||
const specSchema = z.object({
|
||||
environment: z.string(),
|
||||
subMode: z.enum(SubModeEnum).optional(),
|
||||
clientType: z.enum(ClientTypeEnum).optional(),
|
||||
redirectUris: z.array(
|
||||
z.object({
|
||||
url: z.string(),
|
||||
matchingMode: z.enum(['strict', 'regex']),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type SecretData = {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
configuration: string;
|
||||
configurationIssuer: string;
|
||||
authorization: string;
|
||||
token: string;
|
||||
userinfo: string;
|
||||
endSession: string;
|
||||
jwks: string;
|
||||
};
|
||||
class OIDCClient extends CustomResource<typeof specSchema> {
|
||||
public static readonly apiVersion = API_VERSION;
|
||||
public static readonly kind = 'OidcClient';
|
||||
public static readonly spec = specSchema;
|
||||
public static readonly scope = 'Namespaced';
|
||||
|
||||
#environment = new ResourceReference<typeof Environment>();
|
||||
#secret: Secret<SecretData>;
|
||||
|
||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||
super(options);
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#secret = resourceService.get(Secret<SecretData>, `${this.name}-client`, this.namespace);
|
||||
}
|
||||
|
||||
public get appName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public reconcile = async () => {
|
||||
if (!this.spec) {
|
||||
throw new NotReadyError('MissingSpec');
|
||||
}
|
||||
const resourceService = this.services.get(ResourceService);
|
||||
this.#environment.current = resourceService.get(Environment, this.spec.environment);
|
||||
if (!this.#environment.current.exists) {
|
||||
throw new NotReadyError('EnvironmentNotFound');
|
||||
}
|
||||
const authentik = this.#environment.current.authentikServer;
|
||||
const authentikSecret = authentik.secret.value;
|
||||
if (!authentikSecret) {
|
||||
throw new Error('MissingAuthentikSecret');
|
||||
}
|
||||
|
||||
const url = authentik.url;
|
||||
|
||||
await this.#secret.set((current) => ({
|
||||
clientSecret: generateRandomHexPass(),
|
||||
...current,
|
||||
clientId: this.name,
|
||||
configuration: new URL(`/application/o/${this.appName}/.well-known/openid-configuration`, url).toString(),
|
||||
configurationIssuer: new URL(`/application/o/${this.appName}/`, url).toString(),
|
||||
authorization: new URL(`/application/o/${this.appName}/authorize/`, url).toString(),
|
||||
token: new URL(`/application/o/${this.appName}/token/`, url).toString(),
|
||||
userinfo: new URL(`/application/o/${this.appName}/userinfo/`, url).toString(),
|
||||
endSession: new URL(`/application/o/${this.appName}/end-session/`, url).toString(),
|
||||
jwks: new URL(`/application/o/${this.appName}/jwks/`, url).toString(),
|
||||
}));
|
||||
|
||||
const secret = this.#secret.value;
|
||||
if (!secret) {
|
||||
throw new NotReadyError('MissingSecret');
|
||||
}
|
||||
const authentikService = this.services.get(AuthentikService);
|
||||
const authentikServer = await authentikService.get({
|
||||
url: {
|
||||
internal: `http://${authentikSecret.host}`,
|
||||
external: authentikSecret.url,
|
||||
},
|
||||
token: authentikSecret.token,
|
||||
});
|
||||
|
||||
await authentikServer.upsertClient({
|
||||
...this.spec,
|
||||
name: this.name,
|
||||
secret: secret.clientSecret,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { OIDCClient };
|
||||
@@ -8,6 +8,7 @@ import { Resource, type ResourceOptions } from './resource/resource.ts';
|
||||
import { createManifest } from './resources.utils.ts';
|
||||
|
||||
import { K8sService } from '#services/k8s/k8s.ts';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
|
||||
type ResourceClass<T extends KubernetesObject> = (new (options: ResourceOptions<T>) => Resource<T>) & {
|
||||
apiVersion: string;
|
||||
@@ -21,7 +22,11 @@ type InstallableResourceClass<T extends KubernetesObject> = ResourceClass<T> & {
|
||||
scope: 'Namespaced' | 'Cluster';
|
||||
};
|
||||
|
||||
class ResourceService {
|
||||
type ResourceServiceEvents = {
|
||||
changed: (resource: Resource<ExpectedAny>) => void;
|
||||
};
|
||||
|
||||
class ResourceService extends EventEmitter<ResourceServiceEvents> {
|
||||
#services: Services;
|
||||
#registry: Map<
|
||||
ResourceClass<ExpectedAny>,
|
||||
@@ -34,6 +39,7 @@ class ResourceService {
|
||||
>;
|
||||
|
||||
constructor(services: Services) {
|
||||
super();
|
||||
this.#services = services;
|
||||
this.#registry = new Map();
|
||||
}
|
||||
@@ -65,6 +71,10 @@ class ResourceService {
|
||||
}
|
||||
};
|
||||
|
||||
public getAllOfKind = <T extends ResourceClass<ExpectedAny>>(type: T) => {
|
||||
return (this.#registry.get(type)?.resources?.filter((r) => r.exists) as InstanceType<T>[]) || [];
|
||||
};
|
||||
|
||||
public get = <T extends ResourceClass<ExpectedAny>>(type: T, name: string, namespace?: string) => {
|
||||
let resourceRegistry = this.#registry.get(type);
|
||||
if (!resourceRegistry) {
|
||||
@@ -88,6 +98,7 @@ class ResourceService {
|
||||
},
|
||||
services: this.#services,
|
||||
});
|
||||
current.on('changed', this.emit.bind(this, 'changed', current));
|
||||
resources.push(current);
|
||||
}
|
||||
return current as InstanceType<T>;
|
||||
|
||||
Reference in New Issue
Block a user