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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -33,4 +33,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
|
/cloudflare.yaml
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
apiVersion: cert-manager.io/v1
|
apiVersion: cert-manager.io/v1
|
||||||
kind: ClusterIssuer
|
kind: ClusterIssuer
|
||||||
metadata:
|
metadata:
|
||||||
name: letsencrypt-prod
|
name: lets-encrypt-prod
|
||||||
annotations:
|
annotations:
|
||||||
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
|
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
|
||||||
spec:
|
spec:
|
||||||
@@ -15,5 +15,5 @@ spec:
|
|||||||
cloudflare:
|
cloudflare:
|
||||||
email: alice@alice.com
|
email: alice@alice.com
|
||||||
apiTokenSecretRef:
|
apiTokenSecretRef:
|
||||||
name: cloudflare-api-token
|
name: cloudflare
|
||||||
key: api-token
|
key: token
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
apiVersion: homelab.mortenolsen.pro/v1
|
apiVersion: homelab.mortenolsen.pro/v1
|
||||||
kind: AuthentikClient
|
kind: OidcClient
|
||||||
metadata:
|
metadata:
|
||||||
name: '{{ .Release.Name }}'
|
name: '{{ .Release.Name }}'
|
||||||
spec:
|
spec:
|
||||||
server: '{{ .Values.authentikServer }}'
|
environment: '{{ .Values.environment }}'
|
||||||
redirectUris:
|
redirectUris:
|
||||||
- url: https://localhost:3000/api/v1/authentik/oauth2/callback
|
- url: https://localhost:3000/api/v1/authentik/oauth2/callback
|
||||||
matchingMode: strict
|
matchingMode: strict
|
||||||
|
|||||||
11
charts/apps/bytestash/templates/external-http-service.yaml
Normal file
11
charts/apps/bytestash/templates/external-http-service.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: homelab.mortenolsen.pro/v1
|
||||||
|
kind: ExternalHttpService
|
||||||
|
metadata:
|
||||||
|
name: '{{ .Release.Name }}'
|
||||||
|
spec:
|
||||||
|
environment: '{{ .Values.environment }}'
|
||||||
|
subdomain: '{{ .Values.subdomain }}-external'
|
||||||
|
destination:
|
||||||
|
host: '{{ .Release.Name }}.{{ .Release.Namespace }}.svc.cluster.local'
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -25,21 +25,21 @@ spec:
|
|||||||
- name: OIDC_ENABLED
|
- name: OIDC_ENABLED
|
||||||
value: 'true'
|
value: 'true'
|
||||||
- name: OIDC_DISPLAY_NAME
|
- name: OIDC_DISPLAY_NAME
|
||||||
value: Authentik
|
value: OIDC
|
||||||
- name: OIDC_CLIENT_ID
|
- name: OIDC_CLIENT_ID
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: authentik-client-{{ .Release.Name }}
|
name: '{{ .Release.Name }}-client'
|
||||||
key: clientId
|
key: clientId
|
||||||
- name: OIDC_CLIENT_SECRET
|
- name: OIDC_CLIENT_SECRET
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: authentik-client-{{ .Release.Name }}
|
name: '{{ .Release.Name }}-client'
|
||||||
key: clientSecret
|
key: clientSecret
|
||||||
- name: OIDC_ISSUER_URL
|
- name: OIDC_ISSUER_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: authentik-client-{{ .Release.Name }}
|
name: '{{ .Release.Name }}-client'
|
||||||
key: configuration
|
key: configuration
|
||||||
|
|
||||||
# !! IMPORTANT !!
|
# !! IMPORTANT !!
|
||||||
@@ -62,7 +62,7 @@ spec:
|
|||||||
name: bytestash-data
|
name: bytestash-data
|
||||||
spec:
|
spec:
|
||||||
accessModes: ['ReadWriteOnce']
|
accessModes: ['ReadWriteOnce']
|
||||||
storageClassName: '{{ .Values.storageClassName }}'
|
storageClassName: '{{ .Values.environment }}'
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: 5Gi
|
storage: 5Gi
|
||||||
|
|||||||
@@ -1,5 +1,2 @@
|
|||||||
environment: dev/dev
|
environment: dev
|
||||||
postgresCluster: dev/dev-postgres-cluster
|
|
||||||
authentikServer: dev/dev-authentik-server
|
|
||||||
storageClassName: dev-retain
|
|
||||||
subdomain: bytestash
|
subdomain: bytestash
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
apiVersion: homelab.mortenolsen.pro/v1
|
apiVersion: homelab.mortenolsen.pro/v1
|
||||||
kind: AuthentikClient
|
kind: OidcClient
|
||||||
metadata:
|
metadata:
|
||||||
name: test-client
|
name: test-client
|
||||||
spec:
|
spec:
|
||||||
server: dev/dev-authentik-server
|
environment: dev
|
||||||
redirectUris:
|
redirectUris:
|
||||||
- url: https://localhost:3000/api/v1/authentik/oauth2/callback
|
- url: https://localhost:3000/api/v1/authentik/oauth2/callback
|
||||||
matchingMode: strict
|
matchingMode: strict
|
||||||
|
|||||||
14
manifests/test-service.yaml
Normal file
14
manifests/test-service.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: networking.istio.io/v1alpha3
|
||||||
|
kind: ServiceEntry
|
||||||
|
metadata:
|
||||||
|
name: test-example-com
|
||||||
|
namespace: dev
|
||||||
|
spec:
|
||||||
|
hosts:
|
||||||
|
- authentik.one.dev.olsen.cloud
|
||||||
|
# (the address field is optional if you use 'resolution: DNS')
|
||||||
|
ports:
|
||||||
|
- number: 80
|
||||||
|
name: https
|
||||||
|
protocol: HTTPS
|
||||||
|
resolution: DNS
|
||||||
@@ -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 type { Services } from '../utils/service.ts';
|
||||||
|
|
||||||
import { NamespaceService } from './namespaces/namespaces.ts';
|
import { NamespaceService } from './namespaces/namespaces.ts';
|
||||||
@@ -22,10 +24,18 @@ class BootstrapService {
|
|||||||
return this.#services.get(ReleaseService);
|
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 () => {
|
public ensure = async () => {
|
||||||
await this.namespaces.ensure();
|
await this.namespaces.ensure();
|
||||||
await this.repos.ensure();
|
await this.repos.ensure();
|
||||||
await this.releases.ensure();
|
await this.releases.ensure();
|
||||||
|
await this.cloudflareTunnel.ensure({
|
||||||
|
spec: {},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,19 @@ class RepoService {
|
|||||||
#jetstack: HelmRepo;
|
#jetstack: HelmRepo;
|
||||||
#istio: HelmRepo;
|
#istio: HelmRepo;
|
||||||
#authentik: HelmRepo;
|
#authentik: HelmRepo;
|
||||||
|
#cloudflare: HelmRepo;
|
||||||
|
|
||||||
constructor(services: Services) {
|
constructor(services: Services) {
|
||||||
const resourceService = services.get(ResourceService);
|
const resourceService = services.get(ResourceService);
|
||||||
this.#jetstack = resourceService.get(HelmRepo, 'jetstack', NAMESPACE);
|
this.#jetstack = resourceService.get(HelmRepo, 'jetstack', NAMESPACE);
|
||||||
this.#istio = resourceService.get(HelmRepo, 'istio', NAMESPACE);
|
this.#istio = resourceService.get(HelmRepo, 'istio', NAMESPACE);
|
||||||
this.#authentik = resourceService.get(HelmRepo, 'authentik', NAMESPACE);
|
this.#authentik = resourceService.get(HelmRepo, 'authentik', NAMESPACE);
|
||||||
|
this.#cloudflare = resourceService.get(HelmRepo, 'cloudflare', NAMESPACE);
|
||||||
|
|
||||||
this.#jetstack.on('changed', this.ensure);
|
this.#jetstack.on('changed', this.ensure);
|
||||||
this.#istio.on('changed', this.ensure);
|
this.#istio.on('changed', this.ensure);
|
||||||
this.#authentik.on('changed', this.ensure);
|
this.#authentik.on('changed', this.ensure);
|
||||||
|
this.#cloudflare.on('changed', this.ensure);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get jetstack() {
|
public get jetstack() {
|
||||||
@@ -32,6 +35,10 @@ class RepoService {
|
|||||||
return this.#authentik;
|
return this.#authentik;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get cloudflare() {
|
||||||
|
return this.#cloudflare;
|
||||||
|
}
|
||||||
|
|
||||||
public ensure = async () => {
|
public ensure = async () => {
|
||||||
await this.#jetstack.set({
|
await this.#jetstack.set({
|
||||||
url: 'https://charts.jetstack.io',
|
url: 'https://charts.jetstack.io',
|
||||||
@@ -44,6 +51,10 @@ class RepoService {
|
|||||||
await this.#authentik.set({
|
await this.#authentik.set({
|
||||||
url: 'https://charts.goauthentik.io',
|
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);
|
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 () => {
|
public reconcile = async () => {
|
||||||
if (!this.spec) {
|
if (!this.spec) {
|
||||||
throw new NotReadyError('MissingSpec');
|
throw new NotReadyError('MissingSpec');
|
||||||
@@ -240,7 +260,7 @@ class AuthentikServer extends CustomResource<typeof specSchema> {
|
|||||||
ownerReferences: [this.ref],
|
ownerReferences: [this.ref],
|
||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
gateways: [`${gateway.namespace}/${gateway.name}`],
|
gateways: [`${gateway.namespace}/${gateway.name}`, 'mesh'],
|
||||||
hosts: [domain],
|
hosts: [domain],
|
||||||
http: [
|
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 { PROVISIONER } from '#resources/core/pvc/pvc.ts';
|
||||||
import { Gateway } from '#resources/istio/gateway/gateway.ts';
|
import { Gateway } from '#resources/istio/gateway/gateway.ts';
|
||||||
import { NotReadyError } from '#utils/errors.ts';
|
import { NotReadyError } from '#utils/errors.ts';
|
||||||
|
import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts';
|
||||||
|
|
||||||
const specSchema = z.object({
|
const specSchema = z.object({
|
||||||
domain: z.string(),
|
domain: z.string(),
|
||||||
@@ -37,11 +38,12 @@ class Environment extends CustomResource<typeof specSchema> {
|
|||||||
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||||
super(options);
|
super(options);
|
||||||
const resourceService = this.services.get(ResourceService);
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
const namespaceService = this.services.get(NamespaceService);
|
||||||
|
|
||||||
this.#namespace = resourceService.get(Namespace, this.name);
|
this.#namespace = resourceService.get(Namespace, this.name);
|
||||||
this.#namespace.on('changed', this.queueReconcile);
|
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.#certificate.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
this.#storageClass = resourceService.get(StorageClass, this.name);
|
this.#storageClass = resourceService.get(StorageClass, this.name);
|
||||||
@@ -97,9 +99,6 @@ class Environment extends CustomResource<typeof specSchema> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.#certificate.ensure({
|
await this.#certificate.ensure({
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
spec: {
|
||||||
secretName: `${this.name}-tls`,
|
secretName: `${this.name}-tls`,
|
||||||
issuerRef: {
|
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 { AuthentikServer } from './authentik-server/authentik-server.ts';
|
||||||
|
|
||||||
import type { InstallableResourceClass } from '#services/resources/resources.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 = {
|
const homelab = {
|
||||||
PostgresCluster,
|
PostgresCluster,
|
||||||
RedisServer,
|
RedisServer,
|
||||||
Environment,
|
Environment,
|
||||||
|
ExternalHttpService,
|
||||||
|
CloudflareTunnel,
|
||||||
AuthentikServer,
|
AuthentikServer,
|
||||||
PostgresDatabase,
|
PostgresDatabase,
|
||||||
|
OIDCClient,
|
||||||
|
HttpService,
|
||||||
|
GenerateSecret,
|
||||||
} satisfies Record<string, InstallableResourceClass<ExpectedAny>>;
|
} satisfies Record<string, InstallableResourceClass<ExpectedAny>>;
|
||||||
|
|
||||||
export { homelab };
|
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 { createManifest } from './resources.utils.ts';
|
||||||
|
|
||||||
import { K8sService } from '#services/k8s/k8s.ts';
|
import { K8sService } from '#services/k8s/k8s.ts';
|
||||||
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
|
||||||
type ResourceClass<T extends KubernetesObject> = (new (options: ResourceOptions<T>) => Resource<T>) & {
|
type ResourceClass<T extends KubernetesObject> = (new (options: ResourceOptions<T>) => Resource<T>) & {
|
||||||
apiVersion: string;
|
apiVersion: string;
|
||||||
@@ -21,7 +22,11 @@ type InstallableResourceClass<T extends KubernetesObject> = ResourceClass<T> & {
|
|||||||
scope: 'Namespaced' | 'Cluster';
|
scope: 'Namespaced' | 'Cluster';
|
||||||
};
|
};
|
||||||
|
|
||||||
class ResourceService {
|
type ResourceServiceEvents = {
|
||||||
|
changed: (resource: Resource<ExpectedAny>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ResourceService extends EventEmitter<ResourceServiceEvents> {
|
||||||
#services: Services;
|
#services: Services;
|
||||||
#registry: Map<
|
#registry: Map<
|
||||||
ResourceClass<ExpectedAny>,
|
ResourceClass<ExpectedAny>,
|
||||||
@@ -34,6 +39,7 @@ class ResourceService {
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
constructor(services: Services) {
|
constructor(services: Services) {
|
||||||
|
super();
|
||||||
this.#services = services;
|
this.#services = services;
|
||||||
this.#registry = new Map();
|
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) => {
|
public get = <T extends ResourceClass<ExpectedAny>>(type: T, name: string, namespace?: string) => {
|
||||||
let resourceRegistry = this.#registry.get(type);
|
let resourceRegistry = this.#registry.get(type);
|
||||||
if (!resourceRegistry) {
|
if (!resourceRegistry) {
|
||||||
@@ -88,6 +98,7 @@ class ResourceService {
|
|||||||
},
|
},
|
||||||
services: this.#services,
|
services: this.#services,
|
||||||
});
|
});
|
||||||
|
current.on('changed', this.emit.bind(this, 'changed', current));
|
||||||
resources.push(current);
|
resources.push(current);
|
||||||
}
|
}
|
||||||
return current as InstanceType<T>;
|
return current as InstanceType<T>;
|
||||||
|
|||||||
Reference in New Issue
Block a user