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
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM node:23-alpine
|
FROM node:23-slim
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
COPY . .
|
COPY . .
|
||||||
CMD ["node", "src/index.ts"]
|
CMD ["node", "src/index.ts"]
|
||||||
|
|||||||
BIN
all-namespaces.png
Normal file
BIN
all-namespaces.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 798 KiB |
@@ -3,8 +3,8 @@
|
|||||||
# Declare variables to be passed into your templates.
|
# Declare variables to be passed into your templates.
|
||||||
|
|
||||||
image:
|
image:
|
||||||
repository: homelab-operator # ghcr.io/morten-olsen/homelab-operator
|
repository: ghcr.io/morten-olsen/homelab-operator
|
||||||
pullPolicy: Always
|
pullPolicy: IfNotPresent
|
||||||
# Overrides the image tag whose default is the chart appVersion.
|
# Overrides the image tag whose default is the chart appVersion.
|
||||||
tag: main
|
tag: main
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ apiVersion: homelab.mortenolsen.pro/v1
|
|||||||
kind: Environment
|
kind: Environment
|
||||||
metadata:
|
metadata:
|
||||||
name: dev
|
name: dev
|
||||||
namespace: dev
|
|
||||||
spec:
|
spec:
|
||||||
domain: one.dev.olsen.cloud
|
domain: one.dev.olsen.cloud
|
||||||
tls:
|
tls:
|
||||||
issuer: letsencrypt-prod
|
issuer: lets-encrypt-prod
|
||||||
|
|||||||
@@ -35,6 +35,12 @@
|
|||||||
"yaml": "^2.8.0",
|
"yaml": "^2.8.0",
|
||||||
"zod": "^4.0.14"
|
"zod": "^4.0.14"
|
||||||
},
|
},
|
||||||
|
"imports": {
|
||||||
|
"#services/*": "./src/services/*",
|
||||||
|
"#resources/*": "./src/resources/*",
|
||||||
|
"#bootstrap/*": "./src/bootstrap/*",
|
||||||
|
"#utils/*": "./src/utils/*"
|
||||||
|
},
|
||||||
"packageManager": "pnpm@10.6.0",
|
"packageManager": "pnpm@10.6.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|||||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[project]
|
||||||
|
name = "homelab-operator"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"kubediagrams>=0.5.0",
|
||||||
|
]
|
||||||
@@ -12,7 +12,7 @@ build:
|
|||||||
# load: true
|
# load: true
|
||||||
artifacts:
|
artifacts:
|
||||||
# Defines the image to build. It matches the placeholder in deployment.yaml.
|
# Defines the image to build. It matches the placeholder in deployment.yaml.
|
||||||
- image: homelab-operator
|
- image: homelaboperator
|
||||||
context: . # The build context is the root directory
|
context: . # The build context is the root directory
|
||||||
docker:
|
docker:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -22,6 +22,9 @@ manifests:
|
|||||||
releases:
|
releases:
|
||||||
- name: homelab-operator
|
- name: homelab-operator
|
||||||
chartPath: charts/operator
|
chartPath: charts/operator
|
||||||
|
setValueTemplates:
|
||||||
|
image.repository: '{{.IMAGE_REPO_homelaboperator}}'
|
||||||
|
image.tag: '{{.IMAGE_TAG_homelaboperator}}'
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
# Use kubectl to apply the manifests.
|
# Use kubectl to apply the manifests.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { Services } from '../utils/service.ts';
|
|||||||
import { NamespaceService } from './namespaces/namespaces.ts';
|
import { NamespaceService } from './namespaces/namespaces.ts';
|
||||||
import { ReleaseService } from './releases/releases.ts';
|
import { ReleaseService } from './releases/releases.ts';
|
||||||
import { RepoService } from './repos/repos.ts';
|
import { RepoService } from './repos/repos.ts';
|
||||||
import { ClusterIssuerService } from './resources/issuer.ts';
|
|
||||||
|
|
||||||
class BootstrapService {
|
class BootstrapService {
|
||||||
#services: Services;
|
#services: Services;
|
||||||
@@ -23,15 +22,10 @@ class BootstrapService {
|
|||||||
return this.#services.get(ReleaseService);
|
return this.#services.get(ReleaseService);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get clusterIssuer() {
|
|
||||||
return this.#services.get(ClusterIssuerService);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.clusterIssuer.ensure();
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,19 @@
|
|||||||
import { NamespaceInstance } from '../../instances/namespace.ts';
|
|
||||||
import type { Services } from '../../utils/service.ts';
|
import type { Services } from '../../utils/service.ts';
|
||||||
import { ResourceService } from '../../services/resources/resources.ts';
|
import { ResourceService } from '../../services/resources/resources.ts';
|
||||||
|
|
||||||
|
import { Namespace } from '#resources/core/namespace/namespace.ts';
|
||||||
|
|
||||||
class NamespaceService {
|
class NamespaceService {
|
||||||
#homelab: NamespaceInstance;
|
#homelab: Namespace;
|
||||||
#istioSystem: NamespaceInstance;
|
#istioSystem: Namespace;
|
||||||
#certManager: NamespaceInstance;
|
#certManager: Namespace;
|
||||||
|
|
||||||
constructor(services: Services) {
|
constructor(services: Services) {
|
||||||
const resourceService = services.get(ResourceService);
|
const resourceService = services.get(ResourceService);
|
||||||
this.#homelab = resourceService.getInstance(
|
this.#homelab = resourceService.get(Namespace, 'homelab');
|
||||||
{
|
this.#istioSystem = resourceService.get(Namespace, 'istio-system');
|
||||||
apiVersion: 'v1',
|
this.#certManager = resourceService.get(Namespace, 'cert-manager');
|
||||||
kind: 'Namespace',
|
|
||||||
name: 'homelab',
|
|
||||||
},
|
|
||||||
NamespaceInstance,
|
|
||||||
);
|
|
||||||
this.#istioSystem = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Namespace',
|
|
||||||
name: 'istio-system',
|
|
||||||
},
|
|
||||||
NamespaceInstance,
|
|
||||||
);
|
|
||||||
this.#certManager = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Namespace',
|
|
||||||
name: 'cert-manager',
|
|
||||||
},
|
|
||||||
NamespaceInstance,
|
|
||||||
);
|
|
||||||
this.#homelab.on('changed', this.ensure);
|
this.#homelab.on('changed', this.ensure);
|
||||||
this.#istioSystem.on('changed', this.ensure);
|
this.#istioSystem.on('changed', this.ensure);
|
||||||
this.#certManager.on('changed', this.ensure);
|
this.#certManager.on('changed', this.ensure);
|
||||||
|
|||||||
@@ -1,56 +1,26 @@
|
|||||||
import { HelmReleaseInstance } from '../../instances/helm-release.ts';
|
|
||||||
import { ResourceService } from '../../services/resources/resources.ts';
|
import { ResourceService } from '../../services/resources/resources.ts';
|
||||||
import { NAMESPACE } from '../../utils/consts.ts';
|
import { NAMESPACE } from '../../utils/consts.ts';
|
||||||
import { Services } from '../../utils/service.ts';
|
import { Services } from '../../utils/service.ts';
|
||||||
import { NamespaceService } from '../namespaces/namespaces.ts';
|
import { NamespaceService } from '../namespaces/namespaces.ts';
|
||||||
import { RepoService } from '../repos/repos.ts';
|
import { RepoService } from '../repos/repos.ts';
|
||||||
|
|
||||||
|
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
|
||||||
|
|
||||||
class ReleaseService {
|
class ReleaseService {
|
||||||
#services: Services;
|
#services: Services;
|
||||||
#certManager: HelmReleaseInstance;
|
#certManager: HelmRelease;
|
||||||
#istioBase: HelmReleaseInstance;
|
#istioBase: HelmRelease;
|
||||||
#istiod: HelmReleaseInstance;
|
#istiod: HelmRelease;
|
||||||
#istioGateway: HelmReleaseInstance;
|
#istioGateway: HelmRelease;
|
||||||
|
|
||||||
constructor(services: Services) {
|
constructor(services: Services) {
|
||||||
this.#services = services;
|
this.#services = services;
|
||||||
const resourceService = services.get(ResourceService);
|
const resourceService = services.get(ResourceService);
|
||||||
this.#certManager = resourceService.getInstance(
|
this.#certManager = resourceService.get(HelmRelease, 'cert-manager', NAMESPACE);
|
||||||
{
|
this.#istioBase = resourceService.get(HelmRelease, 'istio-base', NAMESPACE);
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
this.#istiod = resourceService.get(HelmRelease, 'istiod', NAMESPACE);
|
||||||
kind: 'HelmRelease',
|
this.#istioGateway = resourceService.get(HelmRelease, 'istio-gateway', NAMESPACE);
|
||||||
name: 'cert-manager',
|
|
||||||
namespace: NAMESPACE,
|
|
||||||
},
|
|
||||||
HelmReleaseInstance,
|
|
||||||
);
|
|
||||||
this.#istioBase = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
name: 'istio-base',
|
|
||||||
namespace: NAMESPACE,
|
|
||||||
},
|
|
||||||
HelmReleaseInstance,
|
|
||||||
);
|
|
||||||
this.#istiod = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
name: 'istiod',
|
|
||||||
namespace: NAMESPACE,
|
|
||||||
},
|
|
||||||
HelmReleaseInstance,
|
|
||||||
);
|
|
||||||
this.#istioGateway = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
name: 'istio-gateway',
|
|
||||||
namespace: NAMESPACE,
|
|
||||||
},
|
|
||||||
HelmReleaseInstance,
|
|
||||||
);
|
|
||||||
this.#certManager.on('changed', this.ensure);
|
this.#certManager.on('changed', this.ensure);
|
||||||
this.#istioBase.on('changed', this.ensure);
|
this.#istioBase.on('changed', this.ensure);
|
||||||
this.#istiod.on('changed', this.ensure);
|
this.#istiod.on('changed', this.ensure);
|
||||||
|
|||||||
@@ -1,110 +1,48 @@
|
|||||||
import type { Services } from '../../utils/service.ts';
|
import type { Services } from '../../utils/service.ts';
|
||||||
import { ResourceService } from '../../services/resources/resources.ts';
|
import { ResourceService } from '../../services/resources/resources.ts';
|
||||||
import { HelmRepoInstance } from '../../instances/helm-repo.ts';
|
|
||||||
import { NAMESPACE } from '../../utils/consts.ts';
|
import { NAMESPACE } from '../../utils/consts.ts';
|
||||||
|
|
||||||
|
import { HelmRepo } from '#resources/flux/helm-repo/helm-repo.ts';
|
||||||
|
|
||||||
class RepoService {
|
class RepoService {
|
||||||
#jetstack: HelmRepoInstance;
|
#jetstack: HelmRepo;
|
||||||
#istio: HelmRepoInstance;
|
#istio: HelmRepo;
|
||||||
#authentik: HelmRepoInstance;
|
#authentik: HelmRepo;
|
||||||
#containerro: HelmRepoInstance;
|
|
||||||
|
|
||||||
constructor(services: Services) {
|
constructor(services: Services) {
|
||||||
const resourceService = services.get(ResourceService);
|
const resourceService = services.get(ResourceService);
|
||||||
this.#jetstack = resourceService.getInstance(
|
this.#jetstack = resourceService.get(HelmRepo, 'jetstack', NAMESPACE);
|
||||||
{
|
this.#istio = resourceService.get(HelmRepo, 'istio', NAMESPACE);
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
this.#authentik = resourceService.get(HelmRepo, 'authentik', NAMESPACE);
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: 'jetstack',
|
|
||||||
namespace: NAMESPACE,
|
|
||||||
},
|
|
||||||
HelmRepoInstance,
|
|
||||||
);
|
|
||||||
this.#istio = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: 'istio',
|
|
||||||
namespace: NAMESPACE,
|
|
||||||
},
|
|
||||||
HelmRepoInstance,
|
|
||||||
);
|
|
||||||
this.#authentik = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: 'authentik',
|
|
||||||
namespace: NAMESPACE,
|
|
||||||
},
|
|
||||||
HelmRepoInstance,
|
|
||||||
);
|
|
||||||
this.#containerro = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: 'containerro',
|
|
||||||
namespace: NAMESPACE,
|
|
||||||
},
|
|
||||||
HelmRepoInstance,
|
|
||||||
);
|
|
||||||
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.#containerro.on('changed', this.ensure);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get jetstack() {
|
public get jetstack() {
|
||||||
return this.#jetstack;
|
return this.#jetstack;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get istio() {
|
public get istio() {
|
||||||
return this.#istio;
|
return this.#istio;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get authentik() {
|
public get authentik() {
|
||||||
return this.#authentik;
|
return this.#authentik;
|
||||||
}
|
}
|
||||||
public get containerro() {
|
|
||||||
return this.#containerro;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ensure = async () => {
|
public ensure = async () => {
|
||||||
await this.#jetstack.ensure({
|
await this.#jetstack.set({
|
||||||
metadata: {
|
url: 'https://charts.jetstack.io',
|
||||||
name: 'jetstack',
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
interval: '1h',
|
|
||||||
url: 'https://charts.jetstack.io',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.#istio.ensure({
|
await this.#istio.set({
|
||||||
metadata: {
|
url: 'https://istio-release.storage.googleapis.com/charts',
|
||||||
name: 'istio',
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
interval: '1h',
|
|
||||||
url: 'https://istio-release.storage.googleapis.com/charts',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.#authentik.ensure({
|
await this.#authentik.set({
|
||||||
metadata: {
|
url: 'https://charts.goauthentik.io',
|
||||||
name: 'authentik',
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
interval: '1h',
|
|
||||||
url: 'https://charts.goauthentik.io',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.#containerro.ensure({
|
|
||||||
metadata: {
|
|
||||||
name: 'containerro',
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
interval: '1h',
|
|
||||||
url: 'https://charts.containeroo.ch',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { ClusterIssuerInstance } from '../../instances/cluster-issuer.ts';
|
|
||||||
import { CustomDefinitionInstance } from '../../instances/custom-resource-definition.ts';
|
|
||||||
import { ResourceService } from '../../services/resources/resources.ts';
|
|
||||||
import type { Services } from '../../utils/service.ts';
|
|
||||||
|
|
||||||
class ClusterIssuerService {
|
|
||||||
#clusterIssuerCrd: CustomDefinitionInstance;
|
|
||||||
#clusterIssuer: ClusterIssuerInstance;
|
|
||||||
|
|
||||||
constructor(services: Services) {
|
|
||||||
const resourceService = services.get(ResourceService);
|
|
||||||
this.#clusterIssuerCrd = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'CustomResourceDefinition',
|
|
||||||
name: 'clusterissuers.cert-manager.io',
|
|
||||||
},
|
|
||||||
CustomDefinitionInstance,
|
|
||||||
);
|
|
||||||
this.#clusterIssuer = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'ClusterIssuer',
|
|
||||||
name: 'cluster-issuer',
|
|
||||||
},
|
|
||||||
ClusterIssuerInstance,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.#clusterIssuerCrd.on('changed', this.ensure);
|
|
||||||
this.#clusterIssuer.on('changed', this.ensure);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ensure = async () => {
|
|
||||||
if (!this.#clusterIssuerCrd.ready) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.#clusterIssuer.ensure({
|
|
||||||
spec: {
|
|
||||||
acme: {
|
|
||||||
server: 'https://acme-v02.api.letsencrypt.org/directory',
|
|
||||||
email: 'admin@example.com',
|
|
||||||
privateKeySecretRef: {
|
|
||||||
name: 'cluster-issuer-key',
|
|
||||||
},
|
|
||||||
solvers: [
|
|
||||||
{
|
|
||||||
dns01: {
|
|
||||||
cloudflare: {
|
|
||||||
email: 'admin@example.com',
|
|
||||||
apiKeySecretRef: {
|
|
||||||
name: 'cloudflare-api-key',
|
|
||||||
key: 'api-key',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ClusterIssuerService };
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
import type { V1Secret } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { RepoService } from '../../bootstrap/repos/repos.ts';
|
|
||||||
import { HelmReleaseInstance } from '../../instances/helm-release.ts';
|
|
||||||
import { SecretInstance } from '../../instances/secret.ts';
|
|
||||||
import {
|
|
||||||
CustomResource,
|
|
||||||
type CustomResourceOptions,
|
|
||||||
type CustomResourceObject,
|
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { ResourceReference } from '../../services/resources/resources.ref.ts';
|
|
||||||
import { ResourceService } from '../../services/resources/resources.ts';
|
|
||||||
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
|
|
||||||
import { SecretService } from '../../services/secrets/secrets.ts';
|
|
||||||
import { API_VERSION } from '../../utils/consts.ts';
|
|
||||||
import { getWithNamespace } from '../../utils/naming.ts';
|
|
||||||
import { decodeSecret, encodeSecret } from '../../utils/secrets.ts';
|
|
||||||
import type { environmentSpecSchema } from '../environment/environment.schemas.ts';
|
|
||||||
import { HttpServiceInstance } from '../../instances/http-service.ts';
|
|
||||||
import type { redisServerSpecSchema } from '../redis-server/redis-server.schemas.ts';
|
|
||||||
import { PostgresDatabaseInstance } from '../../instances/postgres-database.ts';
|
|
||||||
|
|
||||||
import {
|
|
||||||
authentikServerInitSecretSchema,
|
|
||||||
authentikServerSecretSchema,
|
|
||||||
type authentikServerSpecSchema,
|
|
||||||
} from './authentik-server.schemas.ts';
|
|
||||||
|
|
||||||
class AuthentikServerController extends CustomResource<typeof authentikServerSpecSchema> {
|
|
||||||
#environment: ResourceReference<CustomResourceObject<typeof environmentSpecSchema>>;
|
|
||||||
#authentikInitSecret: EnsuredSecret<typeof authentikServerInitSecretSchema>;
|
|
||||||
#authentikSecret: SecretInstance;
|
|
||||||
#authentikRelease: HelmReleaseInstance;
|
|
||||||
#postgresSecret: ResourceReference<V1Secret>;
|
|
||||||
#httpService: HttpServiceInstance;
|
|
||||||
#redisServer: ResourceReference<CustomResourceObject<typeof redisServerSpecSchema>>;
|
|
||||||
#postgresDatabase: PostgresDatabaseInstance;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof authentikServerSpecSchema>) {
|
|
||||||
super(options);
|
|
||||||
const secretService = this.services.get(SecretService);
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
|
|
||||||
this.#environment = new ResourceReference();
|
|
||||||
this.#authentikInitSecret = secretService.ensure({
|
|
||||||
owner: [this.ref],
|
|
||||||
name: `${this.name}-init`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
schema: authentikServerInitSecretSchema,
|
|
||||||
generator: () => ({
|
|
||||||
AUTHENTIK_BOOTSTRAP_TOKEN: crypto.randomUUID(),
|
|
||||||
AUTHENTIK_BOOTSTRAP_PASSWORD: crypto.randomUUID(),
|
|
||||||
AUTHENTIK_BOOTSTRAP_EMAIL: 'admin@example.com',
|
|
||||||
AUTHENTIK_SECRET_KEY: crypto.randomUUID(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
this.#authentikSecret = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
name: `${this.name}-server`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
SecretInstance<typeof authentikServerSecretSchema>,
|
|
||||||
);
|
|
||||||
this.#authentikRelease = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'helm.toolkit.fluxcd.io/v2',
|
|
||||||
kind: 'HelmRelease',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
HelmReleaseInstance,
|
|
||||||
);
|
|
||||||
this.#httpService = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'HttpService',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
HttpServiceInstance,
|
|
||||||
);
|
|
||||||
this.#postgresDatabase = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'PostgresDatabase',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
PostgresDatabaseInstance,
|
|
||||||
);
|
|
||||||
this.#redisServer = new ResourceReference();
|
|
||||||
this.#postgresSecret = new ResourceReference();
|
|
||||||
this.#authentikSecret.on('changed', this.queueReconcile);
|
|
||||||
this.#authentikInitSecret.resource.on('changed', this.queueReconcile);
|
|
||||||
this.#environment.on('changed', this.queueReconcile);
|
|
||||||
this.#authentikRelease.on('changed', this.queueReconcile);
|
|
||||||
this.#postgresSecret.on('changed', this.queueReconcile);
|
|
||||||
this.#postgresDatabase.on('changed', this.queueReconcile);
|
|
||||||
this.#httpService.on('changed', this.queueReconcile);
|
|
||||||
this.#redisServer.on('changed', this.queueReconcile);
|
|
||||||
}
|
|
||||||
|
|
||||||
public reconcile = async () => {
|
|
||||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.#authentikInitSecret.isValid) {
|
|
||||||
await this.markNotReady('MissingAuthentikInitSecret', 'The authentik init secret is not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
const environmentNames = getWithNamespace(this.spec.environment, this.namespace);
|
|
||||||
|
|
||||||
this.#environment.current = resourceService.get({
|
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'Environment',
|
|
||||||
name: environmentNames.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.#postgresDatabase.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
cluster: this.spec.postgresCluster,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const postgresSecret = this.#postgresDatabase.secret;
|
|
||||||
|
|
||||||
if (!postgresSecret.exists) {
|
|
||||||
await this.markNotReady('MissingPostgresSecret', 'The postgres secret is not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const postgresSecretData = decodeSecret(postgresSecret.data) || {};
|
|
||||||
|
|
||||||
if (!this.#environment.current?.exists) {
|
|
||||||
await this.markNotReady(
|
|
||||||
'MissingEnvironment',
|
|
||||||
`Environment ${this.#environment.current?.namespace}/${this.#environment.current?.name} not found`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = this.#environment.current.spec?.domain;
|
|
||||||
if (!domain) {
|
|
||||||
await this.markNotReady('MissingDomain', 'The domain is not set');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const secretData = {
|
|
||||||
url: `https://${this.spec.subdomain}.${domain}`,
|
|
||||||
host: `${this.name}-server.${this.namespace}.svc.cluster.local`,
|
|
||||||
token: this.#authentikInitSecret.value?.AUTHENTIK_BOOTSTRAP_TOKEN ?? '',
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.#authentikSecret.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
data: encodeSecret(secretData),
|
|
||||||
});
|
|
||||||
|
|
||||||
const repoService = this.services.get(RepoService);
|
|
||||||
|
|
||||||
const redisNames = getWithNamespace(this.spec.redisServer, this.namespace);
|
|
||||||
const redisHost = `${redisNames.name}.${redisNames.namespace}.svc.cluster.local`;
|
|
||||||
|
|
||||||
await this.#authentikRelease.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
interval: '60m',
|
|
||||||
chart: {
|
|
||||||
spec: {
|
|
||||||
chart: 'authentik',
|
|
||||||
version: '2025.6.4',
|
|
||||||
sourceRef: {
|
|
||||||
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
|
||||||
kind: 'HelmRepository',
|
|
||||||
name: repoService.authentik.name,
|
|
||||||
namespace: repoService.authentik.namespace,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
values: {
|
|
||||||
global: {
|
|
||||||
envFrom: [
|
|
||||||
{
|
|
||||||
secretRef: {
|
|
||||||
name: this.#authentikInitSecret.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
authentik: {
|
|
||||||
error_reporting: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
postgresql: {
|
|
||||||
host: postgresSecretData.host,
|
|
||||||
name: postgresSecretData.database,
|
|
||||||
user: postgresSecretData.username,
|
|
||||||
password: 'file:///postgres-creds/password',
|
|
||||||
},
|
|
||||||
redis: {
|
|
||||||
host: redisHost,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
volumes: [
|
|
||||||
{
|
|
||||||
name: 'postgres-creds',
|
|
||||||
secret: {
|
|
||||||
secretName: postgresSecret.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
volumeMounts: [
|
|
||||||
{
|
|
||||||
name: 'postgres-creds',
|
|
||||||
mountPath: '/postgres-creds',
|
|
||||||
readOnly: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
worker: {
|
|
||||||
volumes: [
|
|
||||||
{
|
|
||||||
name: 'postgres-creds',
|
|
||||||
secret: {
|
|
||||||
secretName: postgresSecret.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
volumeMounts: [
|
|
||||||
{
|
|
||||||
name: 'postgres-creds',
|
|
||||||
mountPath: '/postgres-creds',
|
|
||||||
readOnly: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.#httpService.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
environment: this.spec.environment,
|
|
||||||
subdomain: this.spec.subdomain,
|
|
||||||
destination: {
|
|
||||||
host: `${this.name}-server.${this.namespace}.svc.cluster.local`,
|
|
||||||
port: {
|
|
||||||
number: 80,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.markReady();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { AuthentikServerController };
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const authentikServerSpecSchema = z.object({
|
|
||||||
redisServer: z.string(),
|
|
||||||
postgresCluster: z.string(),
|
|
||||||
environment: z.string(),
|
|
||||||
subdomain: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const authentikServerInitSecretSchema = z.object({
|
|
||||||
AUTHENTIK_BOOTSTRAP_TOKEN: z.string(),
|
|
||||||
AUTHENTIK_BOOTSTRAP_PASSWORD: z.string(),
|
|
||||||
AUTHENTIK_BOOTSTRAP_EMAIL: z.string(),
|
|
||||||
AUTHENTIK_SECRET_KEY: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const authentikServerSecretSchema = z.object({
|
|
||||||
url: z.string(),
|
|
||||||
host: z.string(),
|
|
||||||
token: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { authentikServerSpecSchema, authentikServerInitSecretSchema, authentikServerSecretSchema };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { authentikServerSpecSchema } from './authentik-server.schemas.ts';
|
|
||||||
import { AuthentikServerController } from './authentik-server.controller.ts';
|
|
||||||
|
|
||||||
const authentikServerDefinition = createCustomResourceDefinition({
|
|
||||||
group: GROUP,
|
|
||||||
version: 'v1',
|
|
||||||
kind: 'AuthentikServer',
|
|
||||||
names: {
|
|
||||||
plural: 'authentikservers',
|
|
||||||
singular: 'authentikserver',
|
|
||||||
},
|
|
||||||
spec: authentikServerSpecSchema,
|
|
||||||
create: (options) => new AuthentikServerController(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { authentikServerDefinition };
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { authentikClientDefinition } from './authentik-client/authentik-client.ts';
|
|
||||||
import { authentikServerDefinition } from './authentik-server/authentik-server.ts';
|
|
||||||
import { environmentDefinition } from './environment/environment.ts';
|
|
||||||
import { generateSecretDefinition } from './generate-secret/generate-secret.ts';
|
|
||||||
import { httpServiceDefinition } from './http-service/http-service.ts';
|
|
||||||
import { postgresClusterDefinition } from './postgres-cluster/postgres-cluster.ts';
|
|
||||||
import { postgresDatabaseDefinition } from './postgres-database/postgres-database.ts';
|
|
||||||
import { redisServerDefinition } from './redis-server/redis-server.ts';
|
|
||||||
|
|
||||||
const customResources = [
|
|
||||||
postgresDatabaseDefinition,
|
|
||||||
authentikClientDefinition,
|
|
||||||
generateSecretDefinition,
|
|
||||||
environmentDefinition,
|
|
||||||
postgresClusterDefinition,
|
|
||||||
authentikServerDefinition,
|
|
||||||
httpServiceDefinition,
|
|
||||||
redisServerDefinition,
|
|
||||||
];
|
|
||||||
|
|
||||||
export { customResources };
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import { CertificateInstance } from '../../instances/certificate.ts';
|
|
||||||
import { CustomDefinitionInstance } from '../../instances/custom-resource-definition.ts';
|
|
||||||
import { NamespaceInstance } from '../../instances/namespace.ts';
|
|
||||||
import {
|
|
||||||
CustomResource,
|
|
||||||
type CustomResourceOptions,
|
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { ResourceService } from '../../services/resources/resources.ts';
|
|
||||||
import { GatewayInstance } from '../../instances/gateway.ts';
|
|
||||||
import { PostgresClusterInstance } from '../../instances/postgres-cluster.ts';
|
|
||||||
import { API_VERSION } from '../../utils/consts.ts';
|
|
||||||
import { AuthentikServerInstance } from '../../instances/authentik-server.ts';
|
|
||||||
import { StorageClassInstance } from '../../instances/storageclass.ts';
|
|
||||||
import { PROVISIONER } from '../../storage-provider/storage-provider.ts';
|
|
||||||
import { RedisServerInstance } from '../../instances/redis-server.ts';
|
|
||||||
import { NamespaceService } from '../../bootstrap/namespaces/namespaces.ts';
|
|
||||||
|
|
||||||
import type { environmentSpecSchema } from './environment.schemas.ts';
|
|
||||||
|
|
||||||
class EnvironmentController extends CustomResource<typeof environmentSpecSchema> {
|
|
||||||
#namespace: NamespaceInstance;
|
|
||||||
#certificateCrd: CustomDefinitionInstance;
|
|
||||||
#certificate: CertificateInstance;
|
|
||||||
#gatewayCrd: CustomDefinitionInstance;
|
|
||||||
#gateway: GatewayInstance;
|
|
||||||
#storageClass: StorageClassInstance;
|
|
||||||
#postgresCluster: PostgresClusterInstance;
|
|
||||||
#authentikServer: AuthentikServerInstance;
|
|
||||||
#redisServer: RedisServerInstance;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof environmentSpecSchema>) {
|
|
||||||
super(options);
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
const namespaceService = this.services.get(NamespaceService);
|
|
||||||
this.#namespace = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Namespace',
|
|
||||||
name: this.namespace,
|
|
||||||
},
|
|
||||||
NamespaceInstance,
|
|
||||||
);
|
|
||||||
this.#certificateCrd = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'apiextensions.k8s.io/v1',
|
|
||||||
kind: 'CustomResourceDefinition',
|
|
||||||
name: 'certificates.cert-manager.io',
|
|
||||||
},
|
|
||||||
CustomDefinitionInstance,
|
|
||||||
);
|
|
||||||
this.#certificate = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'cert-manager.io/v1',
|
|
||||||
kind: 'Certificate',
|
|
||||||
name: `${this.name}-tls`,
|
|
||||||
namespace: namespaceService.homelab.name,
|
|
||||||
},
|
|
||||||
CertificateInstance,
|
|
||||||
);
|
|
||||||
this.#gatewayCrd = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'apiextensions.k8s.io/v1',
|
|
||||||
kind: 'CustomResourceDefinition',
|
|
||||||
name: 'gateways.networking.istio.io',
|
|
||||||
},
|
|
||||||
CustomDefinitionInstance,
|
|
||||||
);
|
|
||||||
this.#gateway = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'networking.istio.io/v1',
|
|
||||||
kind: 'Gateway',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
GatewayInstance,
|
|
||||||
);
|
|
||||||
this.#storageClass = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'storage.k8s.io/v1',
|
|
||||||
kind: 'StorageClass',
|
|
||||||
name: `${this.name}-retain`,
|
|
||||||
},
|
|
||||||
StorageClassInstance,
|
|
||||||
);
|
|
||||||
this.#postgresCluster = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'PostgresCluster',
|
|
||||||
name: `${this.name}-postgres-cluster`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
PostgresClusterInstance,
|
|
||||||
);
|
|
||||||
this.#authentikServer = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'AuthentikServer',
|
|
||||||
name: `${this.name}-authentik-server`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
AuthentikServerInstance,
|
|
||||||
);
|
|
||||||
this.#redisServer = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'RedisServer',
|
|
||||||
name: `${this.name}-redis-server`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
RedisServerInstance,
|
|
||||||
);
|
|
||||||
this.#gatewayCrd.on('changed', this.queueReconcile);
|
|
||||||
this.#gateway.on('changed', this.queueReconcile);
|
|
||||||
this.#certificateCrd.on('changed', this.queueReconcile);
|
|
||||||
this.#namespace.on('changed', this.queueReconcile);
|
|
||||||
this.#certificate.on('changed', this.queueReconcile);
|
|
||||||
this.#postgresCluster.on('changed', this.queueReconcile);
|
|
||||||
this.#authentikServer.on('changed', this.queueReconcile);
|
|
||||||
this.#storageClass.on('changed', this.queueReconcile);
|
|
||||||
this.#redisServer.on('changed', this.queueReconcile);
|
|
||||||
}
|
|
||||||
|
|
||||||
public reconcile = async () => {
|
|
||||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.#namespace.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
labels: {
|
|
||||||
'istio-injection': 'enabled',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (this.#certificateCrd.ready) {
|
|
||||||
await this.#certificate.ensure({
|
|
||||||
spec: {
|
|
||||||
secretName: `${this.name}-tls`,
|
|
||||||
issuerRef: {
|
|
||||||
name: this.spec.tls.issuer,
|
|
||||||
kind: 'ClusterIssuer',
|
|
||||||
},
|
|
||||||
dnsNames: [`*.${this.spec.domain}`],
|
|
||||||
privateKey: {
|
|
||||||
rotationPolicy: 'Always',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (this.#gatewayCrd.ready) {
|
|
||||||
await this.#gateway.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
selector: {
|
|
||||||
istio: 'homelab-istio-gateway',
|
|
||||||
},
|
|
||||||
servers: [
|
|
||||||
{
|
|
||||||
hosts: [`*.${this.spec.domain}`],
|
|
||||||
port: {
|
|
||||||
name: 'http',
|
|
||||||
number: 80,
|
|
||||||
protocol: 'HTTP',
|
|
||||||
},
|
|
||||||
tls: {
|
|
||||||
httpsRedirect: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hosts: [`*.${this.spec.domain}`],
|
|
||||||
port: {
|
|
||||||
name: 'https',
|
|
||||||
number: 443,
|
|
||||||
protocol: 'HTTPS',
|
|
||||||
},
|
|
||||||
tls: {
|
|
||||||
mode: 'SIMPLE',
|
|
||||||
credentialName: `${this.name}-tls`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.#storageClass.ensure({
|
|
||||||
provisioner: PROVISIONER,
|
|
||||||
parameters: {
|
|
||||||
storageLocation: this.spec.storage?.location || `/data/volumes/${this.name}`,
|
|
||||||
reclaimPolicy: 'Retain',
|
|
||||||
allowVolumeExpansion: 'true',
|
|
||||||
volumeBindingMode: 'Immediate',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.#postgresCluster.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
environment: this.name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.#authentikServer.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
environment: `${this.namespace}/${this.name}`,
|
|
||||||
subdomain: 'authentik',
|
|
||||||
postgresCluster: `${this.name}-postgres-cluster`,
|
|
||||||
redisServer: `${this.name}-redis-server`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.#redisServer.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { EnvironmentController };
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const environmentSpecSchema = z.object({
|
|
||||||
domain: z.string(),
|
|
||||||
tls: z.object({
|
|
||||||
issuer: z.string(),
|
|
||||||
}),
|
|
||||||
storage: z
|
|
||||||
.object({
|
|
||||||
location: z.string().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type EnvironmentSpec = z.infer<typeof environmentSpecSchema>;
|
|
||||||
|
|
||||||
export { environmentSpecSchema, type EnvironmentSpec };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { EnvironmentController } from './environment.controller.ts';
|
|
||||||
import { environmentSpecSchema } from './environment.schemas.ts';
|
|
||||||
|
|
||||||
const environmentDefinition = createCustomResourceDefinition({
|
|
||||||
group: GROUP,
|
|
||||||
version: 'v1',
|
|
||||||
kind: 'Environment',
|
|
||||||
names: {
|
|
||||||
plural: 'environments',
|
|
||||||
singular: 'environment',
|
|
||||||
},
|
|
||||||
spec: environmentSpecSchema,
|
|
||||||
create: (options) => new EnvironmentController(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { environmentDefinition };
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { DestinationRuleInstance } from '../../instances/destination-rule.ts';
|
|
||||||
import { VirtualServiceInstance } from '../../instances/virtual-service.ts';
|
|
||||||
import {
|
|
||||||
CustomResource,
|
|
||||||
type CustomResourceObject,
|
|
||||||
type CustomResourceOptions,
|
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { ResourceReference, ResourceService } from '../../services/resources/resources.ts';
|
|
||||||
import { API_VERSION } from '../../utils/consts.ts';
|
|
||||||
import { getWithNamespace } from '../../utils/naming.ts';
|
|
||||||
import { environmentSpecSchema } from '../environment/environment.schemas.ts';
|
|
||||||
|
|
||||||
import { httpServiceSpecSchema } from './http-service.schemas.ts';
|
|
||||||
|
|
||||||
class HttpServiceController extends CustomResource<typeof httpServiceSpecSchema> {
|
|
||||||
#environment: ResourceReference<CustomResourceObject<typeof environmentSpecSchema>>;
|
|
||||||
#virtualService: VirtualServiceInstance;
|
|
||||||
#destinationRule: DestinationRuleInstance;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof httpServiceSpecSchema>) {
|
|
||||||
super(options);
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
this.#environment = new ResourceReference();
|
|
||||||
this.#virtualService = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'networking.istio.io/v1beta1',
|
|
||||||
kind: 'VirtualService',
|
|
||||||
name: `${this.name}-virtual-service`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
VirtualServiceInstance,
|
|
||||||
);
|
|
||||||
this.#destinationRule = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'networking.istio.io/v1beta1',
|
|
||||||
kind: 'DestinationRule',
|
|
||||||
name: `${this.name}-destination-rule`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
DestinationRuleInstance,
|
|
||||||
);
|
|
||||||
this.#destinationRule.on('changed', this.queueReconcile);
|
|
||||||
this.#virtualService.on('changed', this.queueReconcile);
|
|
||||||
this.#environment.on('changed', this.queueReconcile);
|
|
||||||
}
|
|
||||||
|
|
||||||
public reconcile = async () => {
|
|
||||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
const environmentNames = getWithNamespace(this.spec.environment, this.namespace);
|
|
||||||
this.#environment.current = resourceService.get({
|
|
||||||
apiVersion: API_VERSION,
|
|
||||||
kind: 'Environment',
|
|
||||||
name: environmentNames.name,
|
|
||||||
namespace: environmentNames.namespace,
|
|
||||||
});
|
|
||||||
const environment = this.#environment.current;
|
|
||||||
if (!environment?.exists) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.#virtualService.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
hosts: [`${this.spec.subdomain}.${environment.spec?.domain}`],
|
|
||||||
gateways: [`${this.#environment.current.namespace}/${this.#environment.current.name}`],
|
|
||||||
http: [
|
|
||||||
{
|
|
||||||
route: [
|
|
||||||
{
|
|
||||||
destination: {
|
|
||||||
host: this.spec.destination.host,
|
|
||||||
port: this.spec.destination.port,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.#destinationRule.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
host: this.spec.destination.host,
|
|
||||||
trafficPolicy: {
|
|
||||||
tls: {
|
|
||||||
mode: 'DISABLE',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { HttpServiceController };
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const httpServiceSpecSchema = z.object({
|
|
||||||
environment: z.string(),
|
|
||||||
subdomain: z.string(),
|
|
||||||
destination: z.object({
|
|
||||||
host: z.string(),
|
|
||||||
port: z
|
|
||||||
.object({
|
|
||||||
number: z.number().optional(),
|
|
||||||
protocol: z.enum(['http', 'https']).optional(),
|
|
||||||
name: z.string().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { httpServiceSpecSchema };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { HttpServiceController } from './http-service.controller.ts';
|
|
||||||
import { httpServiceSpecSchema } from './http-service.schemas.ts';
|
|
||||||
|
|
||||||
const httpServiceDefinition = createCustomResourceDefinition({
|
|
||||||
group: GROUP,
|
|
||||||
version: 'v1',
|
|
||||||
kind: 'HttpService',
|
|
||||||
names: {
|
|
||||||
plural: 'httpservices',
|
|
||||||
singular: 'httpservice',
|
|
||||||
},
|
|
||||||
spec: httpServiceSpecSchema,
|
|
||||||
create: (options) => new HttpServiceController(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { httpServiceDefinition };
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { ServiceInstance } from '../../instances/service.ts';
|
|
||||||
import { StatefulSetInstance } from '../../instances/stateful-set.ts';
|
|
||||||
import { ResourceService } from '../../services/resources/resources.ts';
|
|
||||||
import {
|
|
||||||
CustomResource,
|
|
||||||
type CustomResourceOptions,
|
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import type { EnsuredSecret } from '../../services/secrets/secrets.secret.ts';
|
|
||||||
import { SecretService } from '../../services/secrets/secrets.ts';
|
|
||||||
|
|
||||||
import { postgresClusterSecretSchema, type postgresClusterSpecSchema } from './postgres-cluster.schemas.ts';
|
|
||||||
|
|
||||||
class PostgresClusterController extends CustomResource<typeof postgresClusterSpecSchema> {
|
|
||||||
#statefulSet: StatefulSetInstance;
|
|
||||||
#headlessService: ServiceInstance;
|
|
||||||
#service: ServiceInstance;
|
|
||||||
#secret: EnsuredSecret<typeof postgresClusterSecretSchema>;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof postgresClusterSpecSchema>) {
|
|
||||||
super(options);
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
const secretService = this.services.get(SecretService);
|
|
||||||
this.#statefulSet = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'apps/v1',
|
|
||||||
kind: 'StatefulSet',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
StatefulSetInstance,
|
|
||||||
);
|
|
||||||
this.#headlessService = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Service',
|
|
||||||
name: `${this.name}-headless`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
ServiceInstance,
|
|
||||||
);
|
|
||||||
this.#service = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Service',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
ServiceInstance,
|
|
||||||
);
|
|
||||||
this.#secret = secretService.ensure({
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
schema: postgresClusterSecretSchema,
|
|
||||||
generator: () => {
|
|
||||||
return {
|
|
||||||
database: 'postgres',
|
|
||||||
host: `${this.name}.${this.namespace}.svc.cluster.local`,
|
|
||||||
port: '5432',
|
|
||||||
username: 'postgres',
|
|
||||||
password: crypto.randomUUID(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.#statefulSet.on('changed', this.queueReconcile);
|
|
||||||
this.#service.on('changed', this.queueReconcile);
|
|
||||||
this.#headlessService.on('changed', this.queueReconcile);
|
|
||||||
this.#secret.resource.on('changed', this.queueReconcile);
|
|
||||||
}
|
|
||||||
|
|
||||||
public reconcile = async () => {
|
|
||||||
if (!this.exists || this.metadata?.deletionTimestamp || !this.#secret.isValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.#headlessService.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
clusterIP: 'None',
|
|
||||||
selector: {
|
|
||||||
app: this.name,
|
|
||||||
},
|
|
||||||
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.#statefulSet.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
replicas: 1,
|
|
||||||
serviceName: this.name,
|
|
||||||
selector: {
|
|
||||||
matchLabels: {
|
|
||||||
app: this.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
template: {
|
|
||||||
metadata: {
|
|
||||||
labels: {
|
|
||||||
app: this.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
containers: [
|
|
||||||
{
|
|
||||||
name: this.name,
|
|
||||||
image: 'postgres:17',
|
|
||||||
ports: [{ containerPort: 5432, name: 'postgres' }],
|
|
||||||
env: [
|
|
||||||
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: this.name, key: 'password' } } },
|
|
||||||
{ name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: this.name, key: 'username' } } },
|
|
||||||
{ name: 'POSTGRES_DB', value: this.name },
|
|
||||||
{ name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' },
|
|
||||||
],
|
|
||||||
volumeMounts: [{ name: this.name, mountPath: '/var/lib/postgresql/data' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
volumeClaimTemplates: [
|
|
||||||
{
|
|
||||||
metadata: {
|
|
||||||
name: this.name,
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
accessModes: ['ReadWriteOnce'],
|
|
||||||
storageClassName: `${this.spec.environment}-retain`,
|
|
||||||
resources: {
|
|
||||||
requests: {
|
|
||||||
storage: this.spec.storage?.size || '1Gi',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.#service.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
type: 'ClusterIP',
|
|
||||||
selector: {
|
|
||||||
app: this.name,
|
|
||||||
},
|
|
||||||
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { PostgresClusterController };
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const postgresClusterSpecSchema = z.object({
|
|
||||||
environment: z.string(),
|
|
||||||
storage: z
|
|
||||||
.object({
|
|
||||||
size: z.string().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const postgresClusterSecretSchema = z.object({
|
|
||||||
database: z.string(),
|
|
||||||
host: z.string(),
|
|
||||||
port: z.string(),
|
|
||||||
username: z.string(),
|
|
||||||
password: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { postgresClusterSpecSchema, postgresClusterSecretSchema };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { PostgresClusterController } from './postgres-cluster.controller.ts';
|
|
||||||
import { postgresClusterSpecSchema } from './postgres-cluster.schemas.ts';
|
|
||||||
|
|
||||||
const postgresClusterDefinition = createCustomResourceDefinition({
|
|
||||||
group: GROUP,
|
|
||||||
version: 'v1',
|
|
||||||
kind: 'PostgresCluster',
|
|
||||||
names: {
|
|
||||||
plural: 'postgres-clusters',
|
|
||||||
singular: 'postgres-cluster',
|
|
||||||
},
|
|
||||||
spec: postgresClusterSpecSchema,
|
|
||||||
create: (options) => new PostgresClusterController(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { postgresClusterDefinition };
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const postgresDatabaseSpecSchema = z.object({
|
|
||||||
cluster: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { postgresDatabaseSpecSchema };
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import type { V1Secret } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CustomResource,
|
|
||||||
type CustomResourceOptions,
|
|
||||||
type SubresourceResult,
|
|
||||||
} from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { PostgresService } from '../../services/postgres/postgres.service.ts';
|
|
||||||
import { ResourceReference } from '../../services/resources/resources.ref.ts';
|
|
||||||
import { ResourceService } from '../../services/resources/resources.ts';
|
|
||||||
import { getWithNamespace } from '../../utils/naming.ts';
|
|
||||||
import { decodeSecret } from '../../utils/secrets.ts';
|
|
||||||
import { postgresClusterSecretSchema } from '../postgres-cluster/postgres-cluster.schemas.ts';
|
|
||||||
import { SecretInstance } from '../../instances/secret.ts';
|
|
||||||
|
|
||||||
import { type postgresDatabaseSpecSchema } from './portgres-database.schemas.ts';
|
|
||||||
|
|
||||||
const SECRET_READY_CONDITION = 'Secret';
|
|
||||||
const DATABASE_READY_CONDITION = 'Database';
|
|
||||||
|
|
||||||
class PostgresDatabaseResource extends CustomResource<typeof postgresDatabaseSpecSchema> {
|
|
||||||
#clusterSecret: ResourceReference<V1Secret>;
|
|
||||||
#databaseSecret: SecretInstance<typeof postgresClusterSecretSchema>;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof postgresDatabaseSpecSchema>) {
|
|
||||||
super(options);
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
|
|
||||||
this.#clusterSecret = new ResourceReference();
|
|
||||||
|
|
||||||
this.#databaseSecret = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
name: `${this.name}-postgres-database`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
SecretInstance<typeof postgresClusterSecretSchema>,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.#updateSecret();
|
|
||||||
this.#clusterSecret.on('changed', this.queueReconcile);
|
|
||||||
this.#databaseSecret.on('changed', this.queueReconcile);
|
|
||||||
}
|
|
||||||
|
|
||||||
get #dbName() {
|
|
||||||
return `${this.namespace}_${this.name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
get #userName() {
|
|
||||||
return `${this.namespace}_${this.name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
#updateSecret = () => {
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
const secretNames = getWithNamespace(this.spec.cluster, this.namespace);
|
|
||||||
this.#clusterSecret.current = resourceService.get({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
name: secretNames.name,
|
|
||||||
namespace: secretNames.namespace,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileSecret = async (): Promise<SubresourceResult> => {
|
|
||||||
const serverSecret = this.#clusterSecret.current;
|
|
||||||
const databaseSecret = this.#databaseSecret;
|
|
||||||
|
|
||||||
if (!serverSecret?.exists || !serverSecret.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
failed: true,
|
|
||||||
reason: 'MissingConnectionSecret',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const serverSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(serverSecret.data));
|
|
||||||
if (!serverSecretData.success || !serverSecretData.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'SecretMissing',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const databaseSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(databaseSecret.data));
|
|
||||||
const expectedSecret = {
|
|
||||||
password: crypto.randomUUID(),
|
|
||||||
host: serverSecretData.data.host,
|
|
||||||
port: serverSecretData.data.port,
|
|
||||||
username: this.#userName,
|
|
||||||
database: this.#dbName,
|
|
||||||
...databaseSecretData.data,
|
|
||||||
};
|
|
||||||
|
|
||||||
await databaseSecret.ensureData(expectedSecret);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#reconcileDatabase = async (): Promise<SubresourceResult> => {
|
|
||||||
const clusterSecret = this.#clusterSecret.current;
|
|
||||||
if (!clusterSecret?.exists || !clusterSecret.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
failed: true,
|
|
||||||
reason: 'MissingConnectionSecret',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectionSecretData = postgresClusterSecretSchema.safeParse(decodeSecret(clusterSecret.data));
|
|
||||||
if (!connectionSecretData.success || !connectionSecretData.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'SecretMissing',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const secretData = postgresClusterSecretSchema.safeParse(decodeSecret(this.#databaseSecret.data));
|
|
||||||
if (!secretData.success || !secretData.data) {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
syncing: true,
|
|
||||||
reason: 'ConnectionSecretMissing',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const postgresService = this.services.get(PostgresService);
|
|
||||||
const database = postgresService.get({
|
|
||||||
...connectionSecretData.data,
|
|
||||||
port: connectionSecretData.data.port ? Number(connectionSecretData.data.port) : 5432,
|
|
||||||
database: connectionSecretData.data.database,
|
|
||||||
});
|
|
||||||
await database.upsertRole({
|
|
||||||
name: secretData.data.username,
|
|
||||||
password: secretData.data.password,
|
|
||||||
});
|
|
||||||
await database.upsertDatabase({
|
|
||||||
name: secretData.data.database,
|
|
||||||
owner: secretData.data.username,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ready: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
public reconcile = async () => {
|
|
||||||
if (!this.exists || this.metadata?.deletionTimestamp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#updateSecret();
|
|
||||||
await Promise.allSettled([
|
|
||||||
this.reconcileSubresource(DATABASE_READY_CONDITION, this.#reconcileDatabase),
|
|
||||||
this.reconcileSubresource(SECRET_READY_CONDITION, this.#reconcileSecret),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const secretReady = this.conditions.get(SECRET_READY_CONDITION)?.status === 'True';
|
|
||||||
const databaseReady = this.conditions.get(DATABASE_READY_CONDITION)?.status === 'True';
|
|
||||||
await this.conditions.set('Ready', {
|
|
||||||
status: secretReady && databaseReady ? 'True' : 'False',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { PostgresDatabaseResource };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { postgresDatabaseSpecSchema } from './portgres-database.schemas.ts';
|
|
||||||
import { PostgresDatabaseResource } from './postgres-database.resource.ts';
|
|
||||||
|
|
||||||
const postgresDatabaseDefinition = createCustomResourceDefinition({
|
|
||||||
group: GROUP,
|
|
||||||
version: 'v1',
|
|
||||||
kind: 'PostgresDatabase',
|
|
||||||
names: {
|
|
||||||
plural: 'postgresdatabases',
|
|
||||||
singular: 'postgresdatabase',
|
|
||||||
},
|
|
||||||
spec: postgresDatabaseSpecSchema,
|
|
||||||
create: (options) => new PostgresDatabaseResource(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { postgresDatabaseDefinition };
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { DeploymentInstance } from '../../instances/deployment.ts';
|
|
||||||
import { ServiceInstance } from '../../instances/service.ts';
|
|
||||||
import { CustomResource } from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import type { CustomResourceOptions } from '../../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { ResourceService } from '../../services/resources/resources.ts';
|
|
||||||
|
|
||||||
import type { redisServerSpecSchema } from './redis-server.schemas.ts';
|
|
||||||
|
|
||||||
class RedisServerController extends CustomResource<typeof redisServerSpecSchema> {
|
|
||||||
#deployment: DeploymentInstance;
|
|
||||||
#service: ServiceInstance;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<typeof redisServerSpecSchema>) {
|
|
||||||
super(options);
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
this.#deployment = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'apps/v1',
|
|
||||||
kind: 'Deployment',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
DeploymentInstance,
|
|
||||||
);
|
|
||||||
this.#service = resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Service',
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
ServiceInstance,
|
|
||||||
);
|
|
||||||
this.#deployment.on('changed', this.queueReconcile);
|
|
||||||
this.#service.on('changed', this.queueReconcile);
|
|
||||||
}
|
|
||||||
|
|
||||||
public reconcile = async () => {
|
|
||||||
await this.#deployment.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
replicas: 1,
|
|
||||||
selector: {
|
|
||||||
matchLabels: {
|
|
||||||
app: this.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
template: {
|
|
||||||
metadata: {
|
|
||||||
labels: {
|
|
||||||
app: this.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
containers: [
|
|
||||||
{
|
|
||||||
name: this.name,
|
|
||||||
image: 'redis:latest',
|
|
||||||
ports: [{ containerPort: 6379 }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.#service.ensure({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: [this.ref],
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
selector: {
|
|
||||||
app: this.name,
|
|
||||||
},
|
|
||||||
ports: [{ port: 6379, targetPort: 6379 }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RedisServerController };
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const redisServerSpecSchema = z.object({});
|
|
||||||
|
|
||||||
export { redisServerSpecSchema };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { RedisServerController } from './redis-server.controller.ts';
|
|
||||||
import { redisServerSpecSchema } from './redis-server.schemas.ts';
|
|
||||||
|
|
||||||
const redisServerDefinition = createCustomResourceDefinition({
|
|
||||||
group: GROUP,
|
|
||||||
version: 'v1',
|
|
||||||
kind: 'RedisServer',
|
|
||||||
names: {
|
|
||||||
plural: 'redis-servers',
|
|
||||||
singular: 'redis-server',
|
|
||||||
},
|
|
||||||
spec: redisServerSpecSchema,
|
|
||||||
create: (options) => new RedisServerController(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { redisServerDefinition };
|
|
||||||
120
src/index.ts
120
src/index.ts
@@ -1,115 +1,17 @@
|
|||||||
import { BootstrapService } from './bootstrap/bootstrap.ts';
|
import { ResourceService } from './services/resources/resources.ts';
|
||||||
import { customResources } from './custom-resouces/custom-resources.ts';
|
|
||||||
import { CustomResourceService } from './services/custom-resources/custom-resources.ts';
|
|
||||||
import { WatcherService } from './services/watchers/watchers.ts';
|
|
||||||
import { StorageProvider } from './storage-provider/storage-provider.ts';
|
|
||||||
import { Services } from './utils/service.ts';
|
import { Services } from './utils/service.ts';
|
||||||
|
import { BootstrapService } from './bootstrap/bootstrap.ts';
|
||||||
|
|
||||||
|
import { resources } from '#resources/resources.ts';
|
||||||
|
import { homelab } from '#resources/homelab/homelab.ts';
|
||||||
|
|
||||||
const services = new Services();
|
const services = new Services();
|
||||||
|
const resourceService = services.get(ResourceService);
|
||||||
|
|
||||||
const watcherService = services.get(WatcherService);
|
await resourceService.install(...Object.values(homelab));
|
||||||
await watcherService.watchCustomGroup('source.toolkit.fluxcd.io', 'v1', ['helmrepositories', 'gitrepositories']);
|
await resourceService.register(...Object.values(resources));
|
||||||
await watcherService.watchCustomGroup('helm.toolkit.fluxcd.io', 'v2', ['helmreleases']);
|
|
||||||
await watcherService.watchCustomGroup('cert-manager.io', 'v1', ['certificates']);
|
|
||||||
await watcherService.watchCustomGroup('networking.k8s.io', 'v1', ['gateways', 'virtualservices']);
|
|
||||||
|
|
||||||
await watcherService
|
const bootstrapService = services.get(BootstrapService);
|
||||||
.create({
|
await bootstrapService.ensure();
|
||||||
path: '/api/v1/namespaces',
|
|
||||||
list: async (k8s) => {
|
|
||||||
return await k8s.api.listNamespace();
|
|
||||||
},
|
|
||||||
verbs: ['add', 'update', 'delete'],
|
|
||||||
transform: (manifest) => ({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Namespace',
|
|
||||||
...manifest,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.start();
|
|
||||||
|
|
||||||
await watcherService
|
console.log('Started');
|
||||||
.create({
|
|
||||||
path: '/api/v1/secrets',
|
|
||||||
list: async (k8s) => {
|
|
||||||
return await k8s.api.listSecretForAllNamespaces();
|
|
||||||
},
|
|
||||||
verbs: ['add', 'update', 'delete'],
|
|
||||||
transform: (manifest) => ({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
...manifest,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.start();
|
|
||||||
|
|
||||||
await watcherService
|
|
||||||
.create({
|
|
||||||
path: '/apis/apps/v1/statefulsets',
|
|
||||||
list: async (k8s) => {
|
|
||||||
return await k8s.apps.listStatefulSetForAllNamespaces({});
|
|
||||||
},
|
|
||||||
verbs: ['add', 'update', 'delete'],
|
|
||||||
transform: (manifest) => ({
|
|
||||||
apiVersion: 'apps/v1',
|
|
||||||
kind: 'StatefulSet',
|
|
||||||
...manifest,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.start();
|
|
||||||
|
|
||||||
await watcherService
|
|
||||||
.create({
|
|
||||||
path: '/apis/apps/v1/deployments',
|
|
||||||
list: async (k8s) => {
|
|
||||||
return await k8s.apps.listDeploymentForAllNamespaces({});
|
|
||||||
},
|
|
||||||
verbs: ['add', 'update', 'delete'],
|
|
||||||
transform: (manifest) => ({
|
|
||||||
apiVersion: 'apps/v1',
|
|
||||||
kind: 'Deployment',
|
|
||||||
...manifest,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.start();
|
|
||||||
|
|
||||||
await watcherService
|
|
||||||
.create({
|
|
||||||
path: '/apis/apiextensions.k8s.io/v1/customresourcedefinitions',
|
|
||||||
list: async (k8s) => {
|
|
||||||
return await k8s.extensionsApi.listCustomResourceDefinition();
|
|
||||||
},
|
|
||||||
verbs: ['add', 'update', 'delete'],
|
|
||||||
transform: (manifest) => ({
|
|
||||||
apiVersion: 'apiextensions.k8s.io/v1',
|
|
||||||
kind: 'CustomResourceDefinition',
|
|
||||||
...manifest,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.start();
|
|
||||||
await watcherService
|
|
||||||
.create({
|
|
||||||
path: '/apis/storage.k8s.io/v1/storageclasses',
|
|
||||||
list: async (k8s) => {
|
|
||||||
return await k8s.storageApi.listStorageClass();
|
|
||||||
},
|
|
||||||
verbs: ['add', 'update', 'delete'],
|
|
||||||
transform: (manifest) => ({
|
|
||||||
apiVersion: 'storage.k8s.io/v1',
|
|
||||||
kind: 'StorageClass',
|
|
||||||
...manifest,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.start();
|
|
||||||
|
|
||||||
const storageProvider = services.get(StorageProvider);
|
|
||||||
await storageProvider.start();
|
|
||||||
|
|
||||||
const bootstrap = services.get(BootstrapService);
|
|
||||||
await bootstrap.ensure();
|
|
||||||
|
|
||||||
const customResourceService = services.get(CustomResourceService);
|
|
||||||
customResourceService.register(...customResources);
|
|
||||||
|
|
||||||
await customResourceService.install(true);
|
|
||||||
await customResourceService.watch();
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import {
|
|
||||||
authentikServerSecretSchema,
|
|
||||||
type authentikServerSpecSchema,
|
|
||||||
} from '../custom-resouces/authentik-server/authentik-server.schemas.ts';
|
|
||||||
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
import { ResourceService } from '../services/resources/resources.ts';
|
|
||||||
|
|
||||||
import { SecretInstance } from './secret.ts';
|
|
||||||
|
|
||||||
class AuthentikServerInstance extends ResourceInstance<CustomResourceObject<typeof authentikServerSpecSchema>> {
|
|
||||||
public get secret() {
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
return resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
name: `${this.name}-server`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
SecretInstance<typeof authentikServerSecretSchema>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { AuthentikServerInstance };
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
import type { K8SCertificateV1 } from '../__generated__/resources/K8SCertificateV1.ts';
|
|
||||||
|
|
||||||
class CertificateInstance extends ResourceInstance<KubernetesObject & K8SCertificateV1> {}
|
|
||||||
|
|
||||||
export { CertificateInstance };
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import type { K8SClusterIssuerV1 } from '../__generated__/resources/K8SClusterIssuerV1.ts';
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
|
|
||||||
class ClusterIssuerInstance extends ResourceInstance<KubernetesObject & K8SClusterIssuerV1> {
|
|
||||||
public get ready() {
|
|
||||||
return this.exists;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ClusterIssuerInstance };
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { V1CustomResourceDefinition } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
|
|
||||||
class CustomDefinitionInstance extends ResourceInstance<V1CustomResourceDefinition> {}
|
|
||||||
|
|
||||||
export { CustomDefinitionInstance };
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { V1Deployment } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.ts';
|
|
||||||
|
|
||||||
class DeploymentInstance extends ResourceInstance<V1Deployment> {
|
|
||||||
public get ready() {
|
|
||||||
return this.exists && this.status?.readyReplicas === this.status?.replicas;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DeploymentInstance };
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
import type { K8SDestinationRuleV1 } from '../__generated__/resources/K8SDestinationRuleV1.ts';
|
|
||||||
|
|
||||||
class DestinationRuleInstance extends ResourceInstance<KubernetesObject & K8SDestinationRuleV1> {
|
|
||||||
public get ready() {
|
|
||||||
return this.exists;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DestinationRuleInstance };
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { environmentSpecSchema } from '../custom-resouces/environment/environment.schemas.ts';
|
|
||||||
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
|
|
||||||
class EnvironmentInstance extends ResourceInstance<CustomResourceObject<typeof environmentSpecSchema>> {}
|
|
||||||
|
|
||||||
export { EnvironmentInstance };
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
import type { K8SGatewayV1 } from '../__generated__/resources/K8SGatewayV1.ts';
|
|
||||||
|
|
||||||
class GatewayInstance extends ResourceInstance<KubernetesObject & K8SGatewayV1> {}
|
|
||||||
|
|
||||||
export { GatewayInstance };
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.ts';
|
|
||||||
import type { K8SGitRepositoryV1 } from '../__generated__/resources/K8SGitRepositoryV1.ts';
|
|
||||||
|
|
||||||
class GitRepoInstance extends ResourceInstance<KubernetesObject & K8SGitRepositoryV1> {
|
|
||||||
public get ready() {
|
|
||||||
return this.exists;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { GitRepoInstance };
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.ts';
|
|
||||||
import type { K8SHelmReleaseV2 } from '../__generated__/resources/K8SHelmReleaseV2.ts';
|
|
||||||
|
|
||||||
class HelmReleaseInstance extends ResourceInstance<KubernetesObject & K8SHelmReleaseV2> {
|
|
||||||
public get ready() {
|
|
||||||
return this.exists;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { HelmReleaseInstance };
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.ts';
|
|
||||||
import type { K8SHelmRepositoryV1 } from '../__generated__/resources/K8SHelmRepositoryV1.ts';
|
|
||||||
|
|
||||||
class HelmRepoInstance extends ResourceInstance<KubernetesObject & K8SHelmRepositoryV1> {
|
|
||||||
public get ready() {
|
|
||||||
if (!this.exists) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const condition = this.getCondition('Ready');
|
|
||||||
return condition?.status === 'True';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { HelmRepoInstance };
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { httpServiceSpecSchema } from '../custom-resouces/http-service/http-service.schemas.ts';
|
|
||||||
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
|
|
||||||
class HttpServiceInstance extends ResourceInstance<CustomResourceObject<typeof httpServiceSpecSchema>> {}
|
|
||||||
|
|
||||||
export { HttpServiceInstance };
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { V1Namespace } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.ts';
|
|
||||||
|
|
||||||
class NamespaceInstance extends ResourceInstance<V1Namespace> {
|
|
||||||
public get ready() {
|
|
||||||
return this.exists;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { NamespaceInstance };
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { postgresClusterSpecSchema } from '../custom-resouces/postgres-cluster/postgres-cluster.schemas.ts';
|
|
||||||
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
|
|
||||||
class PostgresClusterInstance extends ResourceInstance<CustomResourceObject<typeof postgresClusterSpecSchema>> {}
|
|
||||||
|
|
||||||
export { PostgresClusterInstance };
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { postgresClusterSecretSchema } from '../custom-resouces/postgres-cluster/postgres-cluster.schemas.ts';
|
|
||||||
import type { postgresDatabaseSpecSchema } from '../custom-resouces/postgres-database/portgres-database.schemas.ts';
|
|
||||||
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
import { ResourceService } from '../services/resources/resources.ts';
|
|
||||||
|
|
||||||
import { SecretInstance } from './secret.ts';
|
|
||||||
|
|
||||||
class PostgresDatabaseInstance extends ResourceInstance<CustomResourceObject<typeof postgresDatabaseSpecSchema>> {
|
|
||||||
public get secret() {
|
|
||||||
const resourceService = this.services.get(ResourceService);
|
|
||||||
return resourceService.getInstance(
|
|
||||||
{
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
name: `${this.name}-postgres-database`,
|
|
||||||
namespace: this.namespace,
|
|
||||||
},
|
|
||||||
SecretInstance<typeof postgresClusterSecretSchema>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { PostgresDatabaseInstance };
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { CustomResourceObject } from '../services/custom-resources/custom-resources.custom-resource.ts';
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
import type { redisServerSpecSchema } from '../custom-resouces/redis-server/redis-server.schemas.ts';
|
|
||||||
|
|
||||||
class RedisServerInstance extends ResourceInstance<CustomResourceObject<typeof redisServerSpecSchema>> {}
|
|
||||||
|
|
||||||
export { RedisServerInstance };
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { V1Secret } from '@kubernetes/client-node';
|
|
||||||
import type { z, ZodObject } from 'zod';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
import { decodeSecret, encodeSecret } from '../utils/secrets.ts';
|
|
||||||
|
|
||||||
class SecretInstance<T extends ZodObject = ExpectedAny> extends ResourceInstance<V1Secret> {
|
|
||||||
public get values() {
|
|
||||||
return decodeSecret(this.data) as z.infer<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ensureData = async (values: z.infer<T>) => {
|
|
||||||
await this.ensure({
|
|
||||||
data: encodeSecret(values as Record<string, string>),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
public get ready() {
|
|
||||||
return this.exists;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { SecretInstance };
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { V1Service } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.ts';
|
|
||||||
|
|
||||||
class ServiceInstance extends ResourceInstance<V1Service> {
|
|
||||||
public get ready() {
|
|
||||||
return this.exists;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ServiceInstance };
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { V1StatefulSet } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
|
|
||||||
class StatefulSetInstance extends ResourceInstance<V1StatefulSet> {
|
|
||||||
public get ready() {
|
|
||||||
return this.exists && this.manifest?.status?.readyReplicas === this.manifest?.status?.replicas;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { StatefulSetInstance };
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { V1StorageClass } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
|
|
||||||
class StorageClassInstance extends ResourceInstance<V1StorageClass> {}
|
|
||||||
|
|
||||||
export { StorageClassInstance };
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { ResourceInstance } from '../services/resources/resources.instance.ts';
|
|
||||||
import type { K8SVirtualServiceV1 } from '../__generated__/resources/K8SVirtualServiceV1.ts';
|
|
||||||
|
|
||||||
class VirtualServiceInstance extends ResourceInstance<KubernetesObject & K8SVirtualServiceV1> {
|
|
||||||
public get ready() {
|
|
||||||
return this.exists;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { VirtualServiceInstance };
|
|
||||||
9
src/resources/cert-manager/cert-manager.ts
Normal file
9
src/resources/cert-manager/cert-manager.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Certificate } from './certificate/certificate.ts';
|
||||||
|
|
||||||
|
import type { ResourceClass } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
const certManager = {
|
||||||
|
certificate: Certificate,
|
||||||
|
} satisfies Record<string, ResourceClass<ExpectedAny>>;
|
||||||
|
|
||||||
|
export { certManager };
|
||||||
29
src/resources/cert-manager/certificate/certificate.ts
Normal file
29
src/resources/cert-manager/certificate/certificate.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { K8SCertificateV1 } from 'src/__generated__/resources/K8SCertificateV1.ts';
|
||||||
|
|
||||||
|
import { CRD } from '#resources/core/crd/crd.ts';
|
||||||
|
import { Resource, ResourceService, type ResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class Certificate extends Resource<KubernetesObject & K8SCertificateV1> {
|
||||||
|
public static readonly apiVersion = 'cert-manager.io/v1';
|
||||||
|
public static readonly kind = 'Certificate';
|
||||||
|
|
||||||
|
#crd: CRD;
|
||||||
|
|
||||||
|
constructor(options: ResourceOptions<KubernetesObject & K8SCertificateV1>) {
|
||||||
|
super(options);
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
this.#crd = resourceService.get(CRD, 'certificates.cert-manager.io');
|
||||||
|
this.#crd.on('changed', this.#handleCrdChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleCrdChanged = () => {
|
||||||
|
this.emit('changed');
|
||||||
|
};
|
||||||
|
|
||||||
|
public get hasCRD() {
|
||||||
|
return this.#crd.exists;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Certificate };
|
||||||
23
src/resources/core/core.ts
Normal file
23
src/resources/core/core.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { CRD } from './crd/crd.ts';
|
||||||
|
import { Deployment } from './deployment/deployment.ts';
|
||||||
|
import { Namespace } from './namespace/namespace.ts';
|
||||||
|
import { PersistentVolume } from './pv/pv.ts';
|
||||||
|
import { PVC } from './pvc/pvc.ts';
|
||||||
|
import { Secret } from './secret/secret.ts';
|
||||||
|
import { Service } from './service/service.ts';
|
||||||
|
import { StatefulSet } from './stateful-set/stateful-set.ts';
|
||||||
|
import { StorageClass } from './storage-class/storage-class.ts';
|
||||||
|
|
||||||
|
const core = {
|
||||||
|
namespace: Namespace,
|
||||||
|
storageClass: StorageClass,
|
||||||
|
pvc: PVC,
|
||||||
|
pv: PersistentVolume,
|
||||||
|
secret: Secret,
|
||||||
|
crd: CRD,
|
||||||
|
service: Service,
|
||||||
|
deployment: Deployment,
|
||||||
|
statefulSet: StatefulSet,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { core };
|
||||||
10
src/resources/core/crd/crd.ts
Normal file
10
src/resources/core/crd/crd.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { V1CustomResourceDefinition } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class CRD extends Resource<V1CustomResourceDefinition> {
|
||||||
|
public static readonly apiVersion = 'apiextensions.k8s.io/v1';
|
||||||
|
public static readonly kind = 'CustomResourceDefinition';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CRD };
|
||||||
10
src/resources/core/deployment/deployment.ts
Normal file
10
src/resources/core/deployment/deployment.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { V1Deployment } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class Deployment extends Resource<V1Deployment> {
|
||||||
|
public static readonly apiVersion = 'apps/v1';
|
||||||
|
public static readonly kind = 'Deployment';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Deployment };
|
||||||
10
src/resources/core/namespace/namespace.ts
Normal file
10
src/resources/core/namespace/namespace.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { V1Namespace } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class Namespace extends Resource<V1Namespace> {
|
||||||
|
public static readonly apiVersion = 'v1';
|
||||||
|
public static readonly kind = 'Namespace';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Namespace };
|
||||||
10
src/resources/core/pv/pv.ts
Normal file
10
src/resources/core/pv/pv.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { V1PersistentVolume } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class PersistentVolume extends Resource<V1PersistentVolume> {
|
||||||
|
public static readonly apiVersion = 'v1';
|
||||||
|
public static readonly kind = 'PersistentVolume';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PersistentVolume };
|
||||||
74
src/resources/core/pvc/pvc.ts
Normal file
74
src/resources/core/pvc/pvc.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { V1PersistentVolumeClaim } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { StorageClass } from '../storage-class/storage-class.ts';
|
||||||
|
import { PersistentVolume } from '../pv/pv.ts';
|
||||||
|
|
||||||
|
import { Resource, ResourceService } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
const PROVISIONER = 'homelab-operator';
|
||||||
|
|
||||||
|
class PVC extends Resource<V1PersistentVolumeClaim> {
|
||||||
|
public static readonly apiVersion = 'v1';
|
||||||
|
public static readonly kind = 'PersistentVolumeClaim';
|
||||||
|
|
||||||
|
public reconcile = async () => {
|
||||||
|
const storageClassName = this.spec?.storageClassName;
|
||||||
|
if (!storageClassName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
const storageClass = resourceService.get(StorageClass, storageClassName);
|
||||||
|
|
||||||
|
if (!storageClass.exists || storageClass.manifest?.provisioner !== PROVISIONER) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.status?.phase === 'Pending' && !this.spec?.volumeName) {
|
||||||
|
await this.#provisionVolume(storageClass);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#provisionVolume = async (storageClass: StorageClass) => {
|
||||||
|
const pvName = `pv-${this.namespace}-${this.name}`;
|
||||||
|
const storageLocation = storageClass.manifest?.parameters?.storageLocation || '/data/volumes';
|
||||||
|
const target = `${storageLocation}/${this.namespace}/${this.name}`;
|
||||||
|
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
const pv = resourceService.get(PersistentVolume, pvName);
|
||||||
|
|
||||||
|
await pv.ensure({
|
||||||
|
metadata: {
|
||||||
|
name: pvName,
|
||||||
|
labels: {
|
||||||
|
provisioner: PROVISIONER,
|
||||||
|
'pvc-namespace': this.namespace || 'default',
|
||||||
|
'pvc-name': this.name || 'unknown',
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
'pv.kubernetes.io/provisioned-by': PROVISIONER,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
hostPath: {
|
||||||
|
path: target,
|
||||||
|
type: 'DirectoryOrCreate',
|
||||||
|
},
|
||||||
|
capacity: {
|
||||||
|
storage: this.spec?.resources?.requests?.storage ?? '1Gi',
|
||||||
|
},
|
||||||
|
persistentVolumeReclaimPolicy: 'Retain',
|
||||||
|
accessModes: this.spec?.accessModes ?? ['ReadWriteOnce'],
|
||||||
|
storageClassName: this.spec?.storageClassName,
|
||||||
|
claimRef: {
|
||||||
|
uid: this.metadata?.uid,
|
||||||
|
resourceVersion: this.metadata?.resourceVersion,
|
||||||
|
apiVersion: this.apiVersion,
|
||||||
|
kind: 'PersistentVolumeClaim',
|
||||||
|
name: this.name,
|
||||||
|
namespace: this.namespace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PVC, PROVISIONER };
|
||||||
25
src/resources/core/secret/secret.ts
Normal file
25
src/resources/core/secret/secret.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { KubernetesObject, V1Secret } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
import { decodeSecret, encodeSecret } from '#utils/secrets.ts';
|
||||||
|
|
||||||
|
type SetOptions<T extends Record<string, string | undefined>> = T | ((current: T | undefined) => T | Promise<T>);
|
||||||
|
|
||||||
|
class Secret<T extends Record<string, string> = Record<string, string>> extends Resource<V1Secret> {
|
||||||
|
public static readonly apiVersion = 'v1';
|
||||||
|
public static readonly kind = 'Secret';
|
||||||
|
|
||||||
|
public get value() {
|
||||||
|
return decodeSecret(this.data) as T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set = async (options: SetOptions<T>, data?: KubernetesObject) => {
|
||||||
|
const value = typeof options === 'function' ? await Promise.resolve(options(this.value)) : options;
|
||||||
|
await this.ensure({
|
||||||
|
...data,
|
||||||
|
data: encodeSecret(value),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Secret };
|
||||||
14
src/resources/core/service/service.ts
Normal file
14
src/resources/core/service/service.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { V1Service } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class Service extends Resource<V1Service> {
|
||||||
|
public static readonly apiVersion = 'v1';
|
||||||
|
public static readonly kind = 'Service';
|
||||||
|
|
||||||
|
public get hostname() {
|
||||||
|
return `${this.name}.${this.namespace}.svc.cluster.local`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Service };
|
||||||
10
src/resources/core/stateful-set/stateful-set.ts
Normal file
10
src/resources/core/stateful-set/stateful-set.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { V1StatefulSet } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class StatefulSet extends Resource<V1StatefulSet> {
|
||||||
|
public static readonly apiVersion = 'apps/v1';
|
||||||
|
public static readonly kind = 'StatefulSet';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { StatefulSet };
|
||||||
11
src/resources/core/storage-class/storage-class.ts
Normal file
11
src/resources/core/storage-class/storage-class.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { V1StorageClass } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class StorageClass extends Resource<V1StorageClass> {
|
||||||
|
public static readonly apiVersion = 'storage.k8s.io/v1';
|
||||||
|
public static readonly kind = 'StorageClass';
|
||||||
|
public static readonly plural = 'storageclasses';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { StorageClass };
|
||||||
11
src/resources/flux/flux.ts
Normal file
11
src/resources/flux/flux.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { HelmRelease } from './helm-release/helm-release.ts';
|
||||||
|
import { HelmRepo } from './helm-repo/helm-repo.ts';
|
||||||
|
|
||||||
|
import type { ResourceClass } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
const flux = {
|
||||||
|
helmRelease: HelmRelease,
|
||||||
|
helmRepo: HelmRepo,
|
||||||
|
} satisfies Record<string, ResourceClass<ExpectedAny>>;
|
||||||
|
|
||||||
|
export { flux };
|
||||||
42
src/resources/flux/helm-release/helm-release.ts
Normal file
42
src/resources/flux/helm-release/helm-release.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { K8SHelmReleaseV2 } from 'src/__generated__/resources/K8SHelmReleaseV2.ts';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
type SetOptions = {
|
||||||
|
namespace?: string;
|
||||||
|
values?: Record<string, unknown>;
|
||||||
|
chart: {
|
||||||
|
name: string;
|
||||||
|
namespace?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
class HelmRelease extends Resource<KubernetesObject & K8SHelmReleaseV2> {
|
||||||
|
public static readonly apiVersion = 'helm.toolkit.fluxcd.io/v2';
|
||||||
|
public static readonly kind = 'HelmRelease';
|
||||||
|
|
||||||
|
public set = async (options: SetOptions) => {
|
||||||
|
return await this.ensure({
|
||||||
|
spec: {
|
||||||
|
targetNamespace: options.namespace,
|
||||||
|
interval: '1h',
|
||||||
|
values: options.values,
|
||||||
|
chart: {
|
||||||
|
spec: {
|
||||||
|
chart: 'cert-manager',
|
||||||
|
version: 'v1.18.2',
|
||||||
|
sourceRef: {
|
||||||
|
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||||
|
kind: 'HelmRepository',
|
||||||
|
name: options.chart.name,
|
||||||
|
namespace: options.chart.namespace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HelmRelease };
|
||||||
24
src/resources/flux/helm-repo/helm-repo.ts
Normal file
24
src/resources/flux/helm-repo/helm-repo.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { K8SHelmRepositoryV1 } from 'src/__generated__/resources/K8SHelmRepositoryV1.ts';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
type SetOptions = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
class HelmRepo extends Resource<KubernetesObject & K8SHelmRepositoryV1> {
|
||||||
|
public static readonly apiVersion = 'source.toolkit.fluxcd.io/v1';
|
||||||
|
public static readonly kind = 'HelmRepository';
|
||||||
|
public static readonly plural = 'helmrepositories';
|
||||||
|
|
||||||
|
public set = async ({ url }: SetOptions) => {
|
||||||
|
await this.ensure({
|
||||||
|
spec: {
|
||||||
|
interval: '1h',
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HelmRepo };
|
||||||
244
src/resources/homelab/authentik-server/authentik-server.ts
Normal file
244
src/resources/homelab/authentik-server/authentik-server.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { PostgresDatabase } from '../postgres-database/postgres-database.ts';
|
||||||
|
import { Environment } from '../environment/environment.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomResource,
|
||||||
|
ResourceReference,
|
||||||
|
ResourceService,
|
||||||
|
type CustomResourceOptions,
|
||||||
|
} from '#services/resources/resources.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
import { Secret } from '#resources/core/secret/secret.ts';
|
||||||
|
import { generateRandomHexPass } from '#utils/secrets.ts';
|
||||||
|
import { Service } from '#resources/core/service/service.ts';
|
||||||
|
import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts';
|
||||||
|
import { RepoService } from '#bootstrap/repos/repos.ts';
|
||||||
|
import { VirtualService } from '#resources/istio/virtual-service/virtual-service.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({
|
||||||
|
environment: z.string(),
|
||||||
|
subdomain: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SecretData = { url: string; host: string; token: string };
|
||||||
|
type InitSecretData = {
|
||||||
|
AUTHENTIK_BOOTSTRAP_TOKEN: string;
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD: string;
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL: string;
|
||||||
|
AUTHENTIK_SECRET_KEY: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AuthentikServer extends CustomResource<typeof specSchema> {
|
||||||
|
public static readonly apiVersion = API_VERSION;
|
||||||
|
public static readonly kind = 'AuthentikServer';
|
||||||
|
public static readonly spec = specSchema;
|
||||||
|
public static readonly scope = 'Namespaced';
|
||||||
|
|
||||||
|
#environment: ResourceReference<typeof Environment>;
|
||||||
|
#database: PostgresDatabase;
|
||||||
|
#secret: Secret<SecretData>;
|
||||||
|
#initSecret: Secret<InitSecretData>;
|
||||||
|
#service: Service;
|
||||||
|
#helmRelease: HelmRelease;
|
||||||
|
#virtualService: VirtualService;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||||
|
super(options);
|
||||||
|
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
this.#environment = new ResourceReference();
|
||||||
|
this.#environment.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#database = resourceService.get(PostgresDatabase, this.name, this.namespace);
|
||||||
|
this.#database.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#secret = resourceService.get(Secret<SecretData>, this.name, this.namespace);
|
||||||
|
this.#secret.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#initSecret = resourceService.get(Secret<InitSecretData>, `${this.name}-init`, this.namespace);
|
||||||
|
|
||||||
|
this.#service = resourceService.get(Service, `${this.name}-server`, this.namespace);
|
||||||
|
// this.#service.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#helmRelease = resourceService.get(HelmRelease, this.name, this.namespace);
|
||||||
|
this.#helmRelease.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#virtualService = resourceService.get(VirtualService, this.name, this.namespace);
|
||||||
|
this.#virtualService.on('changed', this.queueReconcile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconcile = async () => {
|
||||||
|
if (!this.spec) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
|
||||||
|
this.#environment.current = resourceService.get(Environment, this.spec.environment);
|
||||||
|
if (!this.#environment.current.spec) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#database.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
environment: this.#environment.current.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const databaseSecret = this.#database.secret.value;
|
||||||
|
if (!databaseSecret) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#initSecret.set(
|
||||||
|
(current) => ({
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL: 'admin@example.com',
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD: generateRandomHexPass(24),
|
||||||
|
AUTHENTIK_BOOTSTRAP_TOKEN: generateRandomHexPass(32),
|
||||||
|
AUTHENTIK_SECRET_KEY: generateRandomHexPass(32),
|
||||||
|
...current,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const initSecret = this.#initSecret.value;
|
||||||
|
if (!initSecret) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = `${this.spec?.subdomain || 'authentik'}.${this.#environment.current.spec.domain}`;
|
||||||
|
await this.#secret.set(
|
||||||
|
{
|
||||||
|
url: `https://${domain}`,
|
||||||
|
host: this.#service.hostname,
|
||||||
|
token: initSecret.AUTHENTIK_BOOTSTRAP_TOKEN,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const secret = this.#secret.value;
|
||||||
|
if (!secret) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoService = this.services.get(RepoService);
|
||||||
|
|
||||||
|
await this.#helmRelease.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
interval: '60m',
|
||||||
|
chart: {
|
||||||
|
spec: {
|
||||||
|
chart: 'authentik',
|
||||||
|
version: '2025.6.4',
|
||||||
|
sourceRef: {
|
||||||
|
apiVersion: 'source.toolkit.fluxcd.io/v1',
|
||||||
|
kind: 'HelmRepository',
|
||||||
|
name: repoService.authentik.name,
|
||||||
|
namespace: repoService.authentik.namespace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
global: {
|
||||||
|
envFrom: [
|
||||||
|
{
|
||||||
|
secretRef: {
|
||||||
|
name: this.#initSecret.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
authentik: {
|
||||||
|
error_reporting: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
postgresql: {
|
||||||
|
host: databaseSecret.host,
|
||||||
|
name: databaseSecret.database,
|
||||||
|
user: databaseSecret.user,
|
||||||
|
password: 'file:///postgres-creds/password',
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
host: this.#environment.current.redisServer.service.hostname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
volumes: [
|
||||||
|
{
|
||||||
|
name: 'postgres-creds',
|
||||||
|
secret: {
|
||||||
|
secretName: this.#database.secret.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
volumeMounts: [
|
||||||
|
{
|
||||||
|
name: 'postgres-creds',
|
||||||
|
mountPath: '/postgres-creds',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
volumes: [
|
||||||
|
{
|
||||||
|
name: 'postgres-creds',
|
||||||
|
secret: {
|
||||||
|
secretName: this.#database.secret.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
volumeMounts: [
|
||||||
|
{
|
||||||
|
name: 'postgres-creds',
|
||||||
|
mountPath: '/postgres-creds',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const gateway = this.#environment.current.gateway;
|
||||||
|
await this.#virtualService.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
gateways: [`${gateway.namespace}/${gateway.name}`],
|
||||||
|
hosts: [domain],
|
||||||
|
http: [
|
||||||
|
{
|
||||||
|
route: [
|
||||||
|
{
|
||||||
|
destination: {
|
||||||
|
host: this.#service.hostname,
|
||||||
|
port: {
|
||||||
|
number: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AuthentikServer };
|
||||||
187
src/resources/homelab/environment/environment.ts
Normal file
187
src/resources/homelab/environment/environment.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { PostgresCluster } from '../postgres-cluster/postgres-cluster.ts';
|
||||||
|
import { RedisServer } from '../redis-server/redis-server.ts';
|
||||||
|
import { AuthentikServer } from '../authentik-server/authentik-server.ts';
|
||||||
|
|
||||||
|
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
import { Namespace } from '#resources/core/namespace/namespace.ts';
|
||||||
|
import { Certificate } from '#resources/cert-manager/certificate/certificate.ts';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const specSchema = z.object({
|
||||||
|
domain: z.string(),
|
||||||
|
tls: z.object({
|
||||||
|
issuer: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
class Environment extends CustomResource<typeof specSchema> {
|
||||||
|
public static readonly apiVersion = API_VERSION;
|
||||||
|
public static readonly kind = 'Environment';
|
||||||
|
public static readonly spec = specSchema;
|
||||||
|
public static readonly scope = 'Cluster';
|
||||||
|
|
||||||
|
#namespace: Namespace;
|
||||||
|
#certificate: Certificate;
|
||||||
|
#storageClass: StorageClass;
|
||||||
|
#gateway: Gateway;
|
||||||
|
#postgresCluster: PostgresCluster;
|
||||||
|
#redisServer: RedisServer;
|
||||||
|
#authentikServer: AuthentikServer;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||||
|
super(options);
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
|
||||||
|
this.#namespace = resourceService.get(Namespace, this.name);
|
||||||
|
this.#namespace.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#certificate = resourceService.get(Certificate, this.name, this.name);
|
||||||
|
this.#certificate.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#storageClass = resourceService.get(StorageClass, this.name);
|
||||||
|
this.#storageClass.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#postgresCluster = resourceService.get(PostgresCluster, `${this.name}-postgres-cluster`, this.name);
|
||||||
|
this.#postgresCluster.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#redisServer = resourceService.get(RedisServer, `${this.name}-redis-server`, this.name);
|
||||||
|
this.#redisServer.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#gateway = resourceService.get(Gateway, this.name, this.name);
|
||||||
|
this.#gateway.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#authentikServer = resourceService.get(AuthentikServer, `${this.name}-authentik`, this.name);
|
||||||
|
this.#authentikServer.on('changed', this.queueReconcile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get certificate() {
|
||||||
|
return this.#certificate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get storageClass() {
|
||||||
|
return this.#storageClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get postgresCluster() {
|
||||||
|
return this.#postgresCluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get redisServer() {
|
||||||
|
return this.#redisServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get gateway() {
|
||||||
|
return this.#gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get authentikServer() {
|
||||||
|
return this.#authentikServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconcile = async () => {
|
||||||
|
const { data: spec, success } = specSchema.safeParse(this.spec);
|
||||||
|
if (!success || !spec) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.#namespace.ensure({
|
||||||
|
metadata: {
|
||||||
|
labels: {
|
||||||
|
'istio-injection': 'enabled',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (this.#certificate.hasCRD) {
|
||||||
|
await this.#certificate.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
secretName: `${this.name}-tls`,
|
||||||
|
issuerRef: {
|
||||||
|
name: spec.tls.issuer,
|
||||||
|
kind: 'ClusterIssuer',
|
||||||
|
},
|
||||||
|
dnsNames: [`*.${spec.domain}`],
|
||||||
|
privateKey: {
|
||||||
|
rotationPolicy: 'Always',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.#storageClass.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
provisioner: PROVISIONER,
|
||||||
|
reclaimPolicy: 'Retain',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#postgresCluster.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
storageClass: this.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#redisServer.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#authentikServer.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
environment: this.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#gateway.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
selector: {
|
||||||
|
istio: 'homelab-istio-gateway',
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
hosts: [`*.${spec.domain}`],
|
||||||
|
port: {
|
||||||
|
name: 'http',
|
||||||
|
number: 80,
|
||||||
|
protocol: 'HTTP',
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
httpsRedirect: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hosts: [`*.${spec.domain}`],
|
||||||
|
port: {
|
||||||
|
name: 'https',
|
||||||
|
number: 443,
|
||||||
|
protocol: 'HTTPS',
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
mode: 'SIMPLE',
|
||||||
|
credentialName: `${this.name}-tls`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Environment };
|
||||||
17
src/resources/homelab/homelab.ts
Normal file
17
src/resources/homelab/homelab.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Environment } from './environment/environment.ts';
|
||||||
|
import { PostgresCluster } from './postgres-cluster/postgres-cluster.ts';
|
||||||
|
import { RedisServer } from './redis-server/redis-server.ts';
|
||||||
|
import { PostgresDatabase } from './postgres-database/postgres-database.ts';
|
||||||
|
import { AuthentikServer } from './authentik-server/authentik-server.ts';
|
||||||
|
|
||||||
|
import type { InstallableResourceClass } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
const homelab = {
|
||||||
|
PostgresCluster,
|
||||||
|
RedisServer,
|
||||||
|
Environment,
|
||||||
|
AuthentikServer,
|
||||||
|
PostgresDatabase,
|
||||||
|
} satisfies Record<string, InstallableResourceClass<ExpectedAny>>;
|
||||||
|
|
||||||
|
export { homelab };
|
||||||
172
src/resources/homelab/postgres-cluster/postgres-cluster.ts
Normal file
172
src/resources/homelab/postgres-cluster/postgres-cluster.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Secret } from '#resources/core/secret/secret.ts';
|
||||||
|
import { StatefulSet } from '#resources/core/stateful-set/stateful-set.ts';
|
||||||
|
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
import { Service } from '#resources/core/service/service.ts';
|
||||||
|
import { generateRandomHexPass } from '#utils/secrets.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({
|
||||||
|
storageClass: z.string(),
|
||||||
|
storage: z
|
||||||
|
.object({
|
||||||
|
size: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SecretData = {
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PostgresCluster extends CustomResource<typeof specSchema> {
|
||||||
|
public static readonly apiVersion = API_VERSION;
|
||||||
|
public static readonly kind = 'PostgresCluster';
|
||||||
|
public static readonly spec = specSchema;
|
||||||
|
public static readonly scope = 'Namespaced';
|
||||||
|
|
||||||
|
#secret: Secret<SecretData>;
|
||||||
|
#statefulSet: StatefulSet;
|
||||||
|
#headlessService: Service;
|
||||||
|
#service: Service;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||||
|
super(options);
|
||||||
|
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
this.#secret = resourceService.get(Secret<SecretData>, this.name, this.namespace);
|
||||||
|
this.#secret.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#statefulSet = resourceService.get(StatefulSet, this.name, this.namespace);
|
||||||
|
this.#statefulSet.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#service = resourceService.get(Service, this.name, this.namespace);
|
||||||
|
this.#service.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#headlessService = resourceService.get(Service, `${this.name}-headless`, this.namespace);
|
||||||
|
this.#headlessService.on('changed', this.queueReconcile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get secret() {
|
||||||
|
return this.#secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get statefulSet() {
|
||||||
|
return this.#statefulSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get headlessService() {
|
||||||
|
return this.#headlessService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get service() {
|
||||||
|
return this.#service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconcile = async () => {
|
||||||
|
await this.#secret.set(
|
||||||
|
(current) => ({
|
||||||
|
password: generateRandomHexPass(16),
|
||||||
|
user: 'homelab',
|
||||||
|
database: 'homelab',
|
||||||
|
...current,
|
||||||
|
host: `${this.#service.name}.${this.#service.namespace}.svc.cluster.local`,
|
||||||
|
port: '5432',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const secretName = this.#secret.name;
|
||||||
|
|
||||||
|
await this.#statefulSet.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
replicas: 1,
|
||||||
|
selector: {
|
||||||
|
matchLabels: {
|
||||||
|
app: this.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
metadata: {
|
||||||
|
labels: {
|
||||||
|
app: this.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: this.name,
|
||||||
|
image: 'postgres:17',
|
||||||
|
ports: [{ containerPort: 5432, name: 'postgres' }],
|
||||||
|
env: [
|
||||||
|
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: secretName, key: 'password' } } },
|
||||||
|
{ name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: secretName, key: 'user' } } },
|
||||||
|
{ name: 'POSTGRES_DB', valueFrom: { secretKeyRef: { name: secretName, key: 'database' } } },
|
||||||
|
{ name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' },
|
||||||
|
],
|
||||||
|
volumeMounts: [{ name: this.name, mountPath: '/var/lib/postgresql/data' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
volumeClaimTemplates: [
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
name: this.name,
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
accessModes: ['ReadWriteOnce'],
|
||||||
|
storageClassName: this.spec?.storageClass,
|
||||||
|
resources: {
|
||||||
|
requests: {
|
||||||
|
storage: this.spec?.storage?.size || '1Gi',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#headlessService.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
clusterIP: 'None',
|
||||||
|
selector: {
|
||||||
|
app: this.name,
|
||||||
|
},
|
||||||
|
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#service.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
type: 'ClusterIP',
|
||||||
|
selector: {
|
||||||
|
app: this.name,
|
||||||
|
},
|
||||||
|
ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PostgresCluster };
|
||||||
133
src/resources/homelab/postgres-database/postgres-database.ts
Normal file
133
src/resources/homelab/postgres-database/postgres-database.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { PostgresCluster } from '../postgres-cluster/postgres-cluster.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomResource,
|
||||||
|
ResourceReference,
|
||||||
|
ResourceService,
|
||||||
|
type CustomResourceOptions,
|
||||||
|
} from '#services/resources/resources.ts';
|
||||||
|
import { Secret } from '#resources/core/secret/secret.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
import { getWithNamespace } from '#utils/naming.ts';
|
||||||
|
import { PostgresService } from '#services/postgres/postgres.service.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({
|
||||||
|
environment: z.string().optional(),
|
||||||
|
cluster: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SecretData = {
|
||||||
|
password: string;
|
||||||
|
user: string;
|
||||||
|
database: string;
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeName = (input: string) => {
|
||||||
|
return input.replace(/[^a-zA-Z0-9_]+/g, '_').toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
class PostgresDatabase extends CustomResource<typeof specSchema> {
|
||||||
|
public static readonly apiVersion = API_VERSION;
|
||||||
|
public static readonly kind = 'PostgresDatabase';
|
||||||
|
public static readonly spec = specSchema;
|
||||||
|
public static readonly scope = 'Namespaced';
|
||||||
|
|
||||||
|
#cluster: ResourceReference<typeof PostgresCluster>;
|
||||||
|
#secret: Secret<SecretData>;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||||
|
super(options);
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
|
||||||
|
this.#cluster = new ResourceReference();
|
||||||
|
this.#cluster.on('changed', this.queueReconcile);
|
||||||
|
|
||||||
|
this.#secret = resourceService.get(Secret<SecretData>, `${this.name}-pg-connection`, this.namespace);
|
||||||
|
this.#secret.on('changed', this.queueReconcile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get username() {
|
||||||
|
return sanitizeName(`${this.namespace}_${this.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get database() {
|
||||||
|
return sanitizeName(`${this.namespace}_${this.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get cluster() {
|
||||||
|
return this.#cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get secret() {
|
||||||
|
return this.#secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconcile = async () => {
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
if (this.spec?.cluster) {
|
||||||
|
const clusterNames = getWithNamespace(this.spec.cluster, this.namespace);
|
||||||
|
this.#cluster.current = resourceService.get(PostgresCluster, clusterNames.name, clusterNames.namespace);
|
||||||
|
} else if (this.spec?.environment) {
|
||||||
|
const { Environment } = await import('../environment/environment.ts');
|
||||||
|
const environment = resourceService.get(Environment, this.spec.environment);
|
||||||
|
this.#cluster.current = environment.postgresCluster;
|
||||||
|
} else {
|
||||||
|
this.#cluster.current = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusterSecret = this.#cluster.current.secret.value;
|
||||||
|
if (!clusterSecret) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#secret.set(
|
||||||
|
(current) => ({
|
||||||
|
password: crypto.randomUUID(),
|
||||||
|
user: this.username,
|
||||||
|
database: this.database,
|
||||||
|
...current,
|
||||||
|
host: clusterSecret.host,
|
||||||
|
port: clusterSecret.port,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const secret = this.#secret.value;
|
||||||
|
if (!secret) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const postgresService = this.services.get(PostgresService);
|
||||||
|
const database = postgresService.get({
|
||||||
|
host: clusterSecret.host,
|
||||||
|
port: clusterSecret.port ? Number(clusterSecret.port) : 5432,
|
||||||
|
database: clusterSecret.database,
|
||||||
|
user: clusterSecret.user,
|
||||||
|
password: clusterSecret.password,
|
||||||
|
});
|
||||||
|
const connectionError = await database.ping();
|
||||||
|
if (connectionError) {
|
||||||
|
console.error('Failed to connect', connectionError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await database.upsertRole({
|
||||||
|
name: secret.user,
|
||||||
|
password: secret.password,
|
||||||
|
});
|
||||||
|
await database.upsertDatabase({
|
||||||
|
name: secret.database,
|
||||||
|
owner: secret.user,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PostgresDatabase };
|
||||||
79
src/resources/homelab/redis-server/redis-server.ts
Normal file
79
src/resources/homelab/redis-server/redis-server.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Deployment } from '#resources/core/deployment/deployment.ts';
|
||||||
|
import { Service } from '#resources/core/service/service.ts';
|
||||||
|
import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts';
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
|
||||||
|
const specSchema = z.object({});
|
||||||
|
|
||||||
|
class RedisServer extends CustomResource<typeof specSchema> {
|
||||||
|
public static readonly apiVersion = API_VERSION;
|
||||||
|
public static readonly kind = 'RedisServer';
|
||||||
|
public static readonly spec = specSchema;
|
||||||
|
public static readonly scope = 'Namespaced';
|
||||||
|
|
||||||
|
#deployment: Deployment;
|
||||||
|
#service: Service;
|
||||||
|
|
||||||
|
constructor(options: CustomResourceOptions<typeof specSchema>) {
|
||||||
|
super(options);
|
||||||
|
const resourceService = this.services.get(ResourceService);
|
||||||
|
this.#deployment = resourceService.get(Deployment, this.name, this.namespace);
|
||||||
|
this.#service = resourceService.get(Service, this.name, this.namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get deployment() {
|
||||||
|
return this.#deployment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get service() {
|
||||||
|
return this.#service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconcile = async () => {
|
||||||
|
await this.#deployment.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
replicas: 1,
|
||||||
|
selector: {
|
||||||
|
matchLabels: {
|
||||||
|
app: this.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
metadata: {
|
||||||
|
labels: {
|
||||||
|
app: this.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: this.name,
|
||||||
|
image: 'redis:latest',
|
||||||
|
ports: [{ containerPort: 6379 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.#service.ensure({
|
||||||
|
metadata: {
|
||||||
|
ownerReferences: [this.ref],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
selector: {
|
||||||
|
app: this.name,
|
||||||
|
},
|
||||||
|
ports: [{ port: 6379, targetPort: 6379 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RedisServer };
|
||||||
11
src/resources/istio/destination-rule/destination-rule.ts
Normal file
11
src/resources/istio/destination-rule/destination-rule.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { K8SDestinationRuleV1 } from 'src/__generated__/resources/K8SDestinationRuleV1.ts';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class DestinationRule extends Resource<KubernetesObject & K8SDestinationRuleV1> {
|
||||||
|
public static readonly apiVersion = 'networking.istio.io/v1';
|
||||||
|
public static readonly kind = 'DestinationRule';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DestinationRule };
|
||||||
11
src/resources/istio/gateway/gateway.ts
Normal file
11
src/resources/istio/gateway/gateway.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { K8SGatewayV1 } from 'src/__generated__/resources/K8SGatewayV1.ts';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class Gateway extends Resource<KubernetesObject & K8SGatewayV1> {
|
||||||
|
public static readonly apiVersion = 'networking.istio.io/v1';
|
||||||
|
public static readonly kind = 'Gateway';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Gateway };
|
||||||
11
src/resources/istio/istio.ts
Normal file
11
src/resources/istio/istio.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { DestinationRule } from './destination-rule/destination-rule.ts';
|
||||||
|
import { Gateway } from './gateway/gateway.ts';
|
||||||
|
import { VirtualService } from './virtual-service/virtual-service.ts';
|
||||||
|
|
||||||
|
const istio = {
|
||||||
|
gateway: Gateway,
|
||||||
|
destinationRule: DestinationRule,
|
||||||
|
virtualService: VirtualService,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { istio };
|
||||||
11
src/resources/istio/virtual-service/virtual-service.ts
Normal file
11
src/resources/istio/virtual-service/virtual-service.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { K8SVirtualServiceV1 } from 'src/__generated__/resources/K8SVirtualServiceV1.ts';
|
||||||
|
|
||||||
|
import { Resource } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
class VirtualService extends Resource<KubernetesObject & K8SVirtualServiceV1> {
|
||||||
|
public static readonly apiVersion = 'networking.istio.io/v1';
|
||||||
|
public static readonly kind = 'VirtualService';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { VirtualService };
|
||||||
17
src/resources/resources.ts
Normal file
17
src/resources/resources.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { core } from './core/core.ts';
|
||||||
|
import { flux } from './flux/flux.ts';
|
||||||
|
import { homelab } from './homelab/homelab.ts';
|
||||||
|
import { certManager } from './cert-manager/cert-manager.ts';
|
||||||
|
import { istio } from './istio/istio.ts';
|
||||||
|
|
||||||
|
import type { ResourceClass } from '#services/resources/resources.ts';
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
...core,
|
||||||
|
...flux,
|
||||||
|
...certManager,
|
||||||
|
...istio,
|
||||||
|
...homelab,
|
||||||
|
} satisfies Record<string, ResourceClass<ExpectedAny>>;
|
||||||
|
|
||||||
|
export { resources };
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { ResourceInstance } from '../resources/resources.instance.ts';
|
|
||||||
|
|
||||||
type DependencyRef<T extends ResourceInstance<ExpectedAny>> = {
|
|
||||||
apiVersion: string;
|
|
||||||
kind: string;
|
|
||||||
name: string;
|
|
||||||
namespace?: string;
|
|
||||||
instance: T;
|
|
||||||
};
|
|
||||||
|
|
||||||
class CustomResourceControllerDependencies {
|
|
||||||
public get = <T extends ResourceInstance<ExpectedAny>>(name: string, ref: DependencyRef<T>) => { };
|
|
||||||
}
|
|
||||||
|
|
||||||
export { CustomResourceControllerDependencies };
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { z, ZodAny, ZodType } from 'zod';
|
|
||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import type { Resource } from '../resources/resources.resource.ts';
|
|
||||||
|
|
||||||
import type { CustomResourceControllerDependencies } from './controllers.dependencies.ts';
|
|
||||||
|
|
||||||
type CustomResourceControllerOptions<TSpec extends ZodType> = {
|
|
||||||
resource: Resource<KubernetesObject & { spec: z.infer<TSpec> }>;
|
|
||||||
dependencies: CustomResourceControllerDependencies;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CustomResourceController<TSpec extends ZodType> = (options: CustomResourceControllerOptions<TSpec>) => {
|
|
||||||
reconcile: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CustomResource<TSpec extends ZodAny> = {
|
|
||||||
group: string;
|
|
||||||
version: string;
|
|
||||||
spec: TSpec;
|
|
||||||
scope: 'namespace' | 'cluster';
|
|
||||||
controller: CustomResourceController<TSpec>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type { CustomResource, CustomResourceController };
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { EventEmitter } from 'eventemitter3';
|
|
||||||
import equal from 'deep-equal';
|
|
||||||
|
|
||||||
import type { CustomResource } from './custom-resources.custom-resource.ts';
|
|
||||||
import type { CustomResourceStatus } from './custom-resources.types.ts';
|
|
||||||
|
|
||||||
type CustomResourceStatusOptions = {
|
|
||||||
resource: CustomResource<ExpectedAny>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CustomResourceConditionsEvents = {
|
|
||||||
changed: (type: string, condition: Condition) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Condition = {
|
|
||||||
lastTransitionTime: Date;
|
|
||||||
status: 'True' | 'False' | 'Unknown';
|
|
||||||
syncing?: boolean;
|
|
||||||
failed?: boolean;
|
|
||||||
resource?: boolean;
|
|
||||||
reason?: string;
|
|
||||||
message?: string;
|
|
||||||
observedGeneration?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
class CustomResourceConditions extends EventEmitter<CustomResourceConditionsEvents> {
|
|
||||||
#options: CustomResourceStatusOptions;
|
|
||||||
#conditions: Record<string, Condition>;
|
|
||||||
#changed: boolean;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceStatusOptions) {
|
|
||||||
super();
|
|
||||||
this.#options = options;
|
|
||||||
this.#conditions = Object.fromEntries(
|
|
||||||
(options.resource.status?.conditions || []).map(({ type, lastTransitionTime, ...condition }) => [
|
|
||||||
type,
|
|
||||||
{
|
|
||||||
...condition,
|
|
||||||
lastTransitionTime: new Date(lastTransitionTime),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
options.resource.on('changed', this.#handleChange);
|
|
||||||
this.#changed = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleChange = () => {
|
|
||||||
const { resource } = this.#options;
|
|
||||||
for (const { type, ...condition } of resource.status?.conditions || []) {
|
|
||||||
const next = {
|
|
||||||
...condition,
|
|
||||||
lastTransitionTime: new Date(condition.lastTransitionTime),
|
|
||||||
};
|
|
||||||
const current = this.#conditions[type];
|
|
||||||
const isEqual = equal(current, next);
|
|
||||||
const isNewer = !current || next.lastTransitionTime > current.lastTransitionTime;
|
|
||||||
if (isEqual || !isNewer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#conditions[type] = next;
|
|
||||||
this.emit('changed', type, next);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public get = (type: string): Condition | undefined => {
|
|
||||||
return this.#conditions[type];
|
|
||||||
};
|
|
||||||
|
|
||||||
public set = async (type: string, condition: Omit<Condition, 'lastTransitionTime'>) => {
|
|
||||||
const current = this.#conditions[type];
|
|
||||||
const isEqual = equal(
|
|
||||||
{ ...current, lastTransitionTime: undefined },
|
|
||||||
{ ...condition, lastTransitionTime: undefined },
|
|
||||||
);
|
|
||||||
if (isEqual) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#changed = true;
|
|
||||||
this.#conditions[type] = {
|
|
||||||
...condition,
|
|
||||||
lastTransitionTime: current && current.status === condition.status ? current.lastTransitionTime : new Date(),
|
|
||||||
observedGeneration: this.#options.resource.metadata?.generation,
|
|
||||||
};
|
|
||||||
await this.save();
|
|
||||||
};
|
|
||||||
|
|
||||||
public save = async () => {
|
|
||||||
if (!this.#changed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.#changed = false;
|
|
||||||
const { resource } = this.#options;
|
|
||||||
const status: CustomResourceStatus = {
|
|
||||||
conditions: Object.entries(this.#conditions).map(([type, condition]) => ({
|
|
||||||
...condition,
|
|
||||||
type,
|
|
||||||
lastTransitionTime: condition.lastTransitionTime.toISOString(),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
await resource.patchStatus(status);
|
|
||||||
} catch (error) {
|
|
||||||
this.#changed = true;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { CustomResourceConditions };
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import type { z, ZodObject } from 'zod';
|
|
||||||
import { ApiException, PatchStrategy, setHeaderOptions, type KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
import { EventEmitter } from 'eventemitter3';
|
|
||||||
|
|
||||||
import type { Resource } from '../resources/resources.resource.ts';
|
|
||||||
import type { Services } from '../../utils/service.ts';
|
|
||||||
import { K8sService } from '../k8s/k8s.ts';
|
|
||||||
import { CoalescingQueued } from '../../utils/queues.ts';
|
|
||||||
|
|
||||||
import type { CustomResourceDefinition, CustomResourceStatus } from './custom-resources.types.ts';
|
|
||||||
import { CustomResourceConditions } from './custom-resources.conditions.ts';
|
|
||||||
|
|
||||||
type CustomResourceObject<TSpec extends ZodObject> = KubernetesObject & {
|
|
||||||
spec: z.infer<TSpec>;
|
|
||||||
status?: CustomResourceStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CustomResourceOptions<TSpec extends ZodObject> = {
|
|
||||||
resource: Resource<CustomResourceObject<TSpec>>;
|
|
||||||
services: Services;
|
|
||||||
definition: CustomResourceDefinition<TSpec>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CustomResourceEvents<TSpec extends ZodObject> = {
|
|
||||||
changed: () => void;
|
|
||||||
changedStatus: (options: { previous: CustomResourceStatus; next: CustomResourceStatus }) => void;
|
|
||||||
changedMetadate: (options: { previous: KubernetesObject['metadata']; next: KubernetesObject['metadata'] }) => void;
|
|
||||||
changedSpec: (options: { previous: z.infer<TSpec>; next: z.infer<TSpec> }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SubresourceResult = {
|
|
||||||
ready: boolean;
|
|
||||||
syncing?: boolean;
|
|
||||||
failed?: boolean;
|
|
||||||
reason?: string;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
abstract class CustomResource<TSpec extends ZodObject> extends EventEmitter<CustomResourceEvents<TSpec>> {
|
|
||||||
#options: CustomResourceOptions<TSpec>;
|
|
||||||
#conditions: CustomResourceConditions;
|
|
||||||
#queue: CoalescingQueued<void>;
|
|
||||||
|
|
||||||
constructor(options: CustomResourceOptions<TSpec>) {
|
|
||||||
super();
|
|
||||||
this.#options = options;
|
|
||||||
this.#conditions = new CustomResourceConditions({
|
|
||||||
resource: this,
|
|
||||||
});
|
|
||||||
options.resource.on('changed', this.#handleChanged);
|
|
||||||
this.#queue = new CoalescingQueued({
|
|
||||||
action: async () => {
|
|
||||||
if (this.exists && !this.isValidSpec) {
|
|
||||||
this.services.log.error(
|
|
||||||
`Invalid spec for ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`,
|
|
||||||
this.spec,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('Reconcileing', this.apiVersion, this.kind, this.namespace, this.name);
|
|
||||||
await this.reconcile?.();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public get conditions() {
|
|
||||||
return this.#conditions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get names() {
|
|
||||||
return this.#options.definition.names;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get services() {
|
|
||||||
const { services } = this.#options;
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get resource() {
|
|
||||||
const { resource } = this.#options;
|
|
||||||
return resource;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get apiVersion() {
|
|
||||||
return this.resource.apiVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get kind() {
|
|
||||||
return this.resource.kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get metadata(): KubernetesObject['metadata'] {
|
|
||||||
const metadata = this.resource.metadata;
|
|
||||||
return (
|
|
||||||
metadata || {
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public get name() {
|
|
||||||
return this.resource.specifier.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get namespace() {
|
|
||||||
const namespace = this.resource.specifier.namespace;
|
|
||||||
if (!namespace) {
|
|
||||||
throw new Error('Custom resources needs a namespace');
|
|
||||||
}
|
|
||||||
return namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get exists() {
|
|
||||||
return this.resource.exists;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get ref() {
|
|
||||||
return this.resource.ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get spec(): z.infer<TSpec> {
|
|
||||||
return this.resource.spec as ExpectedAny;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get status() {
|
|
||||||
return this.resource.manifest?.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isSeen() {
|
|
||||||
return this.metadata?.generation === this.status?.observedGeneration;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isValidSpec() {
|
|
||||||
const { success } = this.#options.definition.spec.safeParse(this.spec);
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setup?: () => Promise<void>;
|
|
||||||
public reconcile?: () => Promise<void>;
|
|
||||||
|
|
||||||
public markSeen = async () => {
|
|
||||||
if (this.isSeen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.patchStatus({
|
|
||||||
observedGeneration: this.metadata?.generation,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
public queueReconcile = async () => {
|
|
||||||
return this.#queue.run();
|
|
||||||
};
|
|
||||||
|
|
||||||
#handleChanged = () => {
|
|
||||||
this.emit('changed');
|
|
||||||
};
|
|
||||||
|
|
||||||
public reconcileSubresource = async (name: string, action: () => Promise<SubresourceResult>) => {
|
|
||||||
try {
|
|
||||||
const result = await action();
|
|
||||||
await this.conditions.set(name, {
|
|
||||||
status: result.ready ? 'True' : 'False',
|
|
||||||
syncing: result.syncing,
|
|
||||||
failed: result.failed ?? false,
|
|
||||||
resource: true,
|
|
||||||
reason: result.reason,
|
|
||||||
message: result.message,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
await this.conditions.set(name, {
|
|
||||||
status: 'False',
|
|
||||||
failed: true,
|
|
||||||
reason: 'Failed',
|
|
||||||
resource: true,
|
|
||||||
message: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public markNotReady = async (reason?: string, message?: string) => {
|
|
||||||
await this.conditions.set('Ready', {
|
|
||||||
status: 'False',
|
|
||||||
reason,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
public markReady = async () => {
|
|
||||||
await this.conditions.set('Ready', {
|
|
||||||
status: 'True',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
public patchStatus = async (status: Partial<CustomResourceStatus>) => {
|
|
||||||
const k8s = this.services.get(K8sService);
|
|
||||||
const [group, version] = this.apiVersion?.split('/') || [];
|
|
||||||
try {
|
|
||||||
await k8s.customObjectsApi.patchNamespacedCustomObjectStatus(
|
|
||||||
{
|
|
||||||
group,
|
|
||||||
version,
|
|
||||||
plural: this.names.plural,
|
|
||||||
name: this.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
body: {
|
|
||||||
status,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setHeaderOptions('Content-Type', PatchStrategy.MergePatch),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiException && err.code === 404) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { CustomResource, type CustomResourceOptions, type CustomResourceObject, type SubresourceResult };
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { ApiException, type KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
import type { ZodObject } from 'zod';
|
|
||||||
|
|
||||||
import type { Services } from '../../utils/service.ts';
|
|
||||||
import type { Resource } from '../resources/resources.resource.ts';
|
|
||||||
import { WatcherService } from '../watchers/watchers.ts';
|
|
||||||
import { K8sService } from '../k8s/k8s.ts';
|
|
||||||
import { Queue } from '../queue/queue.ts';
|
|
||||||
|
|
||||||
import type { CustomResourceDefinition } from './custom-resources.types.ts';
|
|
||||||
import type { CustomResource } from './custom-resources.custom-resource.ts';
|
|
||||||
import { createManifest } from './custom-resources.utils.ts';
|
|
||||||
|
|
||||||
type DefinitionItem = {
|
|
||||||
definition: CustomResourceDefinition<ExpectedAny>;
|
|
||||||
queue: Queue;
|
|
||||||
};
|
|
||||||
|
|
||||||
class CustomResourceService {
|
|
||||||
#services: Services;
|
|
||||||
#definitions: DefinitionItem[];
|
|
||||||
#resources: Map<string, CustomResource<ExpectedAny>>;
|
|
||||||
|
|
||||||
constructor(services: Services) {
|
|
||||||
this.#definitions = [];
|
|
||||||
this.#resources = new Map();
|
|
||||||
this.#services = services;
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleChanged = async (resource: Resource<KubernetesObject>) => {
|
|
||||||
const uid = resource.metadata?.uid;
|
|
||||||
if (!uid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let current = this.#resources.get(uid);
|
|
||||||
if (!current) {
|
|
||||||
const entry = this.#definitions.find(
|
|
||||||
({ definition: r }) =>
|
|
||||||
r.version === resource.version &&
|
|
||||||
r.group === resource.group &&
|
|
||||||
r.version === resource.version &&
|
|
||||||
r.kind === resource.kind,
|
|
||||||
);
|
|
||||||
if (!entry) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { definition } = entry;
|
|
||||||
current = definition.create({
|
|
||||||
resource: resource as Resource<ExpectedAny>,
|
|
||||||
services: this.#services,
|
|
||||||
definition,
|
|
||||||
});
|
|
||||||
this.#resources.set(uid, current);
|
|
||||||
await current.setup?.();
|
|
||||||
if (!current.isSeen) {
|
|
||||||
await current.markSeen();
|
|
||||||
}
|
|
||||||
await current.queueReconcile();
|
|
||||||
} else if (!current.isSeen) {
|
|
||||||
await current.markSeen();
|
|
||||||
await current.queueReconcile();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public register = (...resources: CustomResourceDefinition<ExpectedAny>[]) => {
|
|
||||||
this.#definitions.push(
|
|
||||||
...resources.map((definition) => ({
|
|
||||||
definition,
|
|
||||||
queue: new Queue(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
public install = async (replace = false) => {
|
|
||||||
const k8sService = this.#services.get(K8sService);
|
|
||||||
for (const { definition: crd } of this.#definitions) {
|
|
||||||
this.#services.log.info('Installing CRD', { kind: crd.kind });
|
|
||||||
try {
|
|
||||||
const manifest = createManifest(crd);
|
|
||||||
try {
|
|
||||||
await k8sService.extensionsApi.createCustomResourceDefinition({
|
|
||||||
body: manifest,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ApiException && error.code === 409) {
|
|
||||||
if (replace) {
|
|
||||||
await k8sService.extensionsApi.patchCustomResourceDefinition({
|
|
||||||
name: manifest.metadata.name,
|
|
||||||
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ApiException) {
|
|
||||||
throw new Error(`Failed to install ${crd.kind}: ${error.body}`);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public watch = async () => {
|
|
||||||
const watcherService = this.#services.get(WatcherService);
|
|
||||||
for (const { definition, queue } of this.#definitions) {
|
|
||||||
const watcher = watcherService.create({
|
|
||||||
path: `/apis/${definition.group}/${definition.version}/${definition.names.plural}`,
|
|
||||||
list: (k8s) =>
|
|
||||||
k8s.customObjectsApi.listCustomObjectForAllNamespaces({
|
|
||||||
version: definition.version,
|
|
||||||
group: definition.group,
|
|
||||||
plural: definition.names.plural,
|
|
||||||
}),
|
|
||||||
verbs: ['add', 'update', 'delete'],
|
|
||||||
});
|
|
||||||
watcher.on('changed', (resource) => {
|
|
||||||
queue.add(() => this.#handleChanged(resource));
|
|
||||||
});
|
|
||||||
await watcher.start();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const createCustomResourceDefinition = <TSpec extends ZodObject>(options: CustomResourceDefinition<TSpec>) => options;
|
|
||||||
|
|
||||||
export { CustomResourceService, createCustomResourceDefinition };
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { z, type ZodObject } from 'zod';
|
|
||||||
|
|
||||||
import type { CustomResource, CustomResourceOptions } from './custom-resources.custom-resource.ts';
|
|
||||||
|
|
||||||
type CustomResourceDefinition<TSpec extends ZodObject> = {
|
|
||||||
group: string;
|
|
||||||
version: string;
|
|
||||||
kind: string;
|
|
||||||
names: {
|
|
||||||
plural: string;
|
|
||||||
singular: string;
|
|
||||||
};
|
|
||||||
spec: TSpec;
|
|
||||||
create: (options: CustomResourceOptions<TSpec>) => CustomResource<TSpec>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const customResourceStatusSchema = z.object({
|
|
||||||
observedGeneration: z.number().optional(),
|
|
||||||
conditions: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
observedGeneration: z.number().optional(),
|
|
||||||
type: z.string(),
|
|
||||||
status: z.enum(['True', 'False', 'Unknown']),
|
|
||||||
lastTransitionTime: z.string().datetime(),
|
|
||||||
resource: z.boolean().optional(),
|
|
||||||
failed: z.boolean().optional(),
|
|
||||||
syncing: z.boolean().optional(),
|
|
||||||
reason: z.string().optional().optional(),
|
|
||||||
message: z.string().optional().optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type CustomResourceStatus = z.infer<typeof customResourceStatusSchema>;
|
|
||||||
|
|
||||||
export { customResourceStatusSchema, type CustomResourceDefinition, type CustomResourceStatus };
|
|
||||||
@@ -8,7 +8,7 @@ type PostgresInstanceOptions = {
|
|||||||
services: Services;
|
services: Services;
|
||||||
host: string;
|
host: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
username: string;
|
user: string;
|
||||||
password: string;
|
password: string;
|
||||||
database?: string;
|
database?: string;
|
||||||
};
|
};
|
||||||
@@ -20,10 +20,10 @@ class PostgresInstance {
|
|||||||
this.#db = knex({
|
this.#db = knex({
|
||||||
client: 'pg',
|
client: 'pg',
|
||||||
connection: {
|
connection: {
|
||||||
host: process.env.FORCE_PG_HOST ?? options.host,
|
host: options.host,
|
||||||
user: process.env.FORCE_PG_USER ?? options.username,
|
user: options.user,
|
||||||
password: process.env.FORCE_PG_PASSWORD ?? options.password,
|
password: options.password,
|
||||||
port: process.env.FORCE_PG_PORT ? parseInt(process.env.FORCE_PG_PORT) : options.port,
|
port: options.port,
|
||||||
database: options.database,
|
database: options.database,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -32,29 +32,32 @@ class PostgresInstance {
|
|||||||
public ping = async () => {
|
public ping = async () => {
|
||||||
try {
|
try {
|
||||||
await this.#db.raw('SELECT 1');
|
await this.#db.raw('SELECT 1');
|
||||||
return true;
|
return;
|
||||||
} catch {
|
} catch (err) {
|
||||||
return false;
|
return err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public upsertRole = async (role: PostgresRole) => {
|
public upsertRole = async (role: PostgresRole) => {
|
||||||
const existingRole = await this.#db.raw('SELECT 1 FROM pg_roles WHERE rolname = ?', [role.name]);
|
const name = role.name;
|
||||||
|
const existingRole = await this.#db.raw('SELECT 1 FROM pg_roles WHERE rolname = ?', [name]);
|
||||||
|
|
||||||
if (existingRole.rows.length === 0) {
|
if (existingRole.rows.length === 0) {
|
||||||
await this.#db.raw(`CREATE ROLE "${role.name}" WITH LOGIN PASSWORD '${role.password}'`);
|
await this.#db.raw(`CREATE ROLE "${name}" WITH LOGIN PASSWORD '${role.password}'`);
|
||||||
} else {
|
} else {
|
||||||
await this.#db.raw(`ALTER ROLE "${role.name}" WITH PASSWORD '${role.password}'`);
|
await this.#db.raw(`ALTER ROLE "${name}" WITH PASSWORD '${role.password}'`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public upsertDatabase = async (database: PostgresDatabase) => {
|
public upsertDatabase = async (database: PostgresDatabase) => {
|
||||||
const existingDatabase = await this.#db.raw('SELECT * FROM pg_database WHERE datname = ?', [database.name]);
|
const owner = database.owner;
|
||||||
|
const name = database.name;
|
||||||
|
const existingDatabase = await this.#db.raw('SELECT * FROM pg_database WHERE datname = ?', [name]);
|
||||||
|
|
||||||
if (existingDatabase.rows.length === 0) {
|
if (existingDatabase.rows.length === 0) {
|
||||||
await this.#db.raw(`CREATE DATABASE "${database.name}" OWNER "${database.owner}"`);
|
await this.#db.raw(`CREATE DATABASE "${name}" OWNER "${owner}"`);
|
||||||
} else {
|
} else {
|
||||||
await this.#db.raw(`ALTER DATABASE "${database.name}" OWNER TO "${database.owner}"`);
|
await this.#db.raw(`ALTER DATABASE "${name}" OWNER TO "${owner}"`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/services/resources/resource/resource.custom.ts
Normal file
34
src/services/resources/resource/resource.custom.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { z, type ZodType } from 'zod';
|
||||||
|
import type { KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
import { Resource, type ResourceOptions } from './resource.ts';
|
||||||
|
|
||||||
|
import { API_VERSION } from '#utils/consts.ts';
|
||||||
|
|
||||||
|
const customResourceStatusSchema = z.object({
|
||||||
|
observedGeneration: z.number().optional(),
|
||||||
|
conditions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
observedGeneration: z.number().optional(),
|
||||||
|
type: z.string(),
|
||||||
|
status: z.enum(['True', 'False', 'Unknown']),
|
||||||
|
lastTransitionTime: z.string().datetime(),
|
||||||
|
resource: z.boolean().optional(),
|
||||||
|
failed: z.boolean().optional(),
|
||||||
|
syncing: z.boolean().optional(),
|
||||||
|
reason: z.string().optional().optional(),
|
||||||
|
message: z.string().optional().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CustomResourceOptions<TSpec extends ZodType> = ResourceOptions<KubernetesObject & { spec: z.infer<TSpec> }>;
|
||||||
|
|
||||||
|
class CustomResource<TSpec extends ZodType> extends Resource<KubernetesObject & { spec: z.infer<TSpec> }> {
|
||||||
|
public static readonly apiVersion = API_VERSION;
|
||||||
|
public static readonly status = customResourceStatusSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CustomResource, type CustomResourceOptions };
|
||||||
38
src/services/resources/resource/resource.reference.ts
Normal file
38
src/services/resources/resource/resource.reference.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
|
||||||
|
import type { ResourceClass } from '../resources.ts';
|
||||||
|
|
||||||
|
import type { ResourceEvents } from './resource.ts';
|
||||||
|
|
||||||
|
class ResourceReference<T extends ResourceClass<ExpectedAny>> extends EventEmitter<ResourceEvents> {
|
||||||
|
#current?: InstanceType<T>;
|
||||||
|
|
||||||
|
constructor(current?: InstanceType<T>) {
|
||||||
|
super();
|
||||||
|
this.#current = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get current() {
|
||||||
|
return this.#current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set current(value: InstanceType<T> | undefined) {
|
||||||
|
const previous = this.#current;
|
||||||
|
if (this.#current) {
|
||||||
|
this.#current.off('changed', this.#handleChange);
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
value.on('changed', this.#handleChange);
|
||||||
|
}
|
||||||
|
this.#current = value;
|
||||||
|
if (previous !== value) {
|
||||||
|
this.emit('changed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleChange = () => {
|
||||||
|
this.emit('changed');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ResourceReference };
|
||||||
@@ -7,6 +7,8 @@ import { Queue } from '../../queue/queue.ts';
|
|||||||
import { K8sService } from '../../k8s/k8s.ts';
|
import { K8sService } from '../../k8s/k8s.ts';
|
||||||
import { isDeepSubset } from '../../../utils/objects.ts';
|
import { isDeepSubset } from '../../../utils/objects.ts';
|
||||||
|
|
||||||
|
import { CoalescingQueued } from '#utils/queues.ts';
|
||||||
|
|
||||||
type ResourceSelector = {
|
type ResourceSelector = {
|
||||||
apiVersion: string;
|
apiVersion: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -27,6 +29,7 @@ type ResourceEvents = {
|
|||||||
class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents> {
|
class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents> {
|
||||||
#manifest?: T;
|
#manifest?: T;
|
||||||
#queue: Queue;
|
#queue: Queue;
|
||||||
|
#reconcileQueue: CoalescingQueued<void>;
|
||||||
#options: ResourceOptions<T>;
|
#options: ResourceOptions<T>;
|
||||||
|
|
||||||
constructor(options: ResourceOptions<T>) {
|
constructor(options: ResourceOptions<T>) {
|
||||||
@@ -34,6 +37,29 @@ class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents>
|
|||||||
this.#options = options;
|
this.#options = options;
|
||||||
this.#manifest = options.manifest;
|
this.#manifest = options.manifest;
|
||||||
this.#queue = new Queue({ concurrency: 1 });
|
this.#queue = new Queue({ concurrency: 1 });
|
||||||
|
this.#reconcileQueue = new CoalescingQueued({
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
if (!this.exists || !this.reconcile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('Reconcileing', this.apiVersion, this.kind, this.namespace, this.name);
|
||||||
|
await this.reconcile?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.on('changed', this.queueReconcile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public reconcile?: () => Promise<void>;
|
||||||
|
public queueReconcile = () => {
|
||||||
|
return this.#reconcileQueue.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
public get services() {
|
||||||
|
return this.#options.services;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get manifest() {
|
public get manifest() {
|
||||||
@@ -169,4 +195,4 @@ class Resource<T extends KubernetesObject> extends EventEmitter<ResourceEvents>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Resource, type ResourceOptions };
|
export { Resource, type ResourceOptions, type ResourceEvents };
|
||||||
|
|||||||
@@ -1,43 +1,128 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
import { ApiException, type KubernetesObject } from '@kubernetes/client-node';
|
||||||
|
import type { ZodType } from 'zod';
|
||||||
|
|
||||||
import type { Services } from '../../utils/service.ts';
|
import type { Services } from '../../utils/service.ts';
|
||||||
import { WatcherService } from '../watchers/watchers.ts';
|
import { WatcherService } from '../watchers/watchers.ts';
|
||||||
|
|
||||||
import type { Resource, ResourceOptions } from './resource/resource.ts';
|
import { Resource, type ResourceOptions } from './resource/resource.ts';
|
||||||
|
import { createManifest } from './resources.utils.ts';
|
||||||
|
|
||||||
type ResourceClass<T extends KubernetesObject> = new (options: ResourceOptions<T>) => Resource<T>;
|
import { K8sService } from '#services/k8s/k8s.ts';
|
||||||
|
|
||||||
type RegisterOptions<T extends KubernetesObject> = {
|
type ResourceClass<T extends KubernetesObject> = (new (options: ResourceOptions<T>) => Resource<T>) & {
|
||||||
apiVersion: string;
|
apiVersion: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
plural?: string;
|
plural?: string;
|
||||||
type: ResourceClass<T>;
|
};
|
||||||
|
|
||||||
|
type InstallableResourceClass<T extends KubernetesObject> = ResourceClass<T> & {
|
||||||
|
spec: ZodType;
|
||||||
|
status: ZodType;
|
||||||
|
scope: 'Namespaced' | 'Cluster';
|
||||||
};
|
};
|
||||||
|
|
||||||
class ResourceService {
|
class ResourceService {
|
||||||
#services: Services;
|
#services: Services;
|
||||||
#registry: Map<Resource<ExpectedAny>, Resource<ExpectedAny>[]>;
|
#registry: Map<
|
||||||
|
ResourceClass<ExpectedAny>,
|
||||||
|
{
|
||||||
|
apiVersion: string;
|
||||||
|
kind: string;
|
||||||
|
plural?: string;
|
||||||
|
resources: Resource<ExpectedAny>[];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
constructor(services: Services) {
|
constructor(services: Services) {
|
||||||
this.#services = services;
|
this.#services = services;
|
||||||
this.#registry = new Map();
|
this.#registry = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
public register = async <T extends KubernetesObject>(options: RegisterOptions<T>) => {
|
public register = async (...resources: ResourceClass<ExpectedAny>[]) => {
|
||||||
const watcherService = this.#services.get(WatcherService);
|
for (const resource of resources) {
|
||||||
const watcher = watcherService.create({});
|
if (!this.#registry.has(resource)) {
|
||||||
watcher.on('changed', (manifest) => {
|
this.#registry.set(resource, {
|
||||||
const { name, namespace } = manifest.metadata || {};
|
apiVersion: resource.apiVersion,
|
||||||
if (!name) {
|
kind: resource.kind,
|
||||||
return;
|
plural: resource.plural,
|
||||||
|
resources: [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const current = this.get(options.type, name, namespace);
|
const watcherService = this.#services.get(WatcherService);
|
||||||
current.manifest = manifest;
|
const watcher = watcherService.create({
|
||||||
});
|
...resource,
|
||||||
await watcher.start();
|
verbs: ['add', 'update', 'delete'],
|
||||||
|
});
|
||||||
|
watcher.on('changed', (manifest) => {
|
||||||
|
const { name, namespace } = manifest.metadata || {};
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = this.get(resource, name, namespace);
|
||||||
|
current.manifest = manifest;
|
||||||
|
});
|
||||||
|
await watcher.start();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public get = <T extends KubernetesObject>(type: ResourceClass<T>, name: string, namespace?: string) => {};
|
public get = <T extends ResourceClass<ExpectedAny>>(type: T, name: string, namespace?: string) => {
|
||||||
|
let resourceRegistry = this.#registry.get(type);
|
||||||
|
if (!resourceRegistry) {
|
||||||
|
resourceRegistry = {
|
||||||
|
apiVersion: type.apiVersion,
|
||||||
|
kind: type.kind,
|
||||||
|
plural: type.plural,
|
||||||
|
resources: [],
|
||||||
|
};
|
||||||
|
this.#registry.set(type, resourceRegistry);
|
||||||
|
}
|
||||||
|
const { resources, apiVersion, kind } = resourceRegistry;
|
||||||
|
let current = resources.find((resource) => resource.name === name && resource.namespace === namespace);
|
||||||
|
if (!current) {
|
||||||
|
current = new type({
|
||||||
|
selector: {
|
||||||
|
apiVersion,
|
||||||
|
kind,
|
||||||
|
name,
|
||||||
|
namespace,
|
||||||
|
},
|
||||||
|
services: this.#services,
|
||||||
|
});
|
||||||
|
resources.push(current);
|
||||||
|
}
|
||||||
|
return current as InstanceType<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
public install = async (...resources: InstallableResourceClass<ExpectedAny>[]) => {
|
||||||
|
const k8sService = this.#services.get(K8sService);
|
||||||
|
for (const resource of resources) {
|
||||||
|
this.#services.log.info('Installing CRD', { kind: resource.kind });
|
||||||
|
try {
|
||||||
|
const manifest = createManifest(resource);
|
||||||
|
try {
|
||||||
|
await k8sService.extensionsApi.createCustomResourceDefinition({
|
||||||
|
body: manifest,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiException && error.code === 409) {
|
||||||
|
await k8sService.extensionsApi.patchCustomResourceDefinition({
|
||||||
|
name: manifest.metadata.name,
|
||||||
|
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiException) {
|
||||||
|
throw new Error(`Failed to install ${resource.kind}: ${error.body}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ResourceService };
|
export { CustomResource, type CustomResourceOptions } from './resource/resource.custom.ts';
|
||||||
|
export { ResourceReference } from './resource/resource.reference.ts';
|
||||||
|
export { ResourceService, Resource, type ResourceOptions, type ResourceClass, type InstallableResourceClass };
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { customResourceStatusSchema, type CustomResourceDefinition } from './custom-resources.types.ts';
|
import type { InstallableResourceClass } from './resources.ts';
|
||||||
|
|
||||||
const createManifest = (defintion: CustomResourceDefinition<ExpectedAny>) => {
|
const createManifest = (defintion: InstallableResourceClass<ExpectedAny>) => {
|
||||||
|
const plural = defintion.plural ?? defintion.kind.toLowerCase() + 's';
|
||||||
|
const [version, group] = defintion.apiVersion.split('/').toReversed();
|
||||||
return {
|
return {
|
||||||
apiVersion: 'apiextensions.k8s.io/v1',
|
apiVersion: 'apiextensions.k8s.io/v1',
|
||||||
kind: 'CustomResourceDefinition',
|
kind: 'CustomResourceDefinition',
|
||||||
metadata: {
|
metadata: {
|
||||||
name: `${defintion.names.plural}.${defintion.group}`,
|
name: `${plural}.${group}`,
|
||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
group: defintion.group,
|
group: group,
|
||||||
names: {
|
names: {
|
||||||
kind: defintion.kind,
|
kind: defintion.kind,
|
||||||
plural: defintion.names.plural,
|
plural: plural,
|
||||||
singular: defintion.names.singular,
|
singular: defintion.kind.toLowerCase(),
|
||||||
},
|
},
|
||||||
scope: 'Namespaced',
|
scope: defintion.scope,
|
||||||
versions: [
|
versions: [
|
||||||
{
|
{
|
||||||
name: defintion.version,
|
name: version,
|
||||||
served: true,
|
served: true,
|
||||||
storage: true,
|
storage: true,
|
||||||
schema: {
|
schema: {
|
||||||
@@ -27,12 +29,12 @@ const createManifest = (defintion: CustomResourceDefinition<ExpectedAny>) => {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
spec: {
|
spec: {
|
||||||
...z.toJSONSchema(defintion.spec.strict(), { io: 'input' }),
|
...z.toJSONSchema(defintion.spec, { io: 'input' }),
|
||||||
$schema: undefined,
|
$schema: undefined,
|
||||||
additionalProperties: undefined,
|
additionalProperties: undefined,
|
||||||
} as ExpectedAny,
|
} as ExpectedAny,
|
||||||
status: {
|
status: {
|
||||||
...z.toJSONSchema(customResourceStatusSchema.strict(), { io: 'input' }),
|
...z.toJSONSchema(defintion.status, { io: 'input' }),
|
||||||
$schema: undefined,
|
$schema: undefined,
|
||||||
additionalProperties: undefined,
|
additionalProperties: undefined,
|
||||||
} as ExpectedAny,
|
} as ExpectedAny,
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import { isDeepSubset } from '../../utils/objects.ts';
|
|
||||||
|
|
||||||
import { ResourceReference } from './resources.ref.ts';
|
|
||||||
|
|
||||||
abstract class ResourceInstance<T extends KubernetesObject> extends ResourceReference<T> {
|
|
||||||
public get resource() {
|
|
||||||
if (!this.current) {
|
|
||||||
throw new Error('Instance needs a resource');
|
|
||||||
}
|
|
||||||
return this.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get services() {
|
|
||||||
return this.resource.services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get exists() {
|
|
||||||
return this.resource.exists;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get manifest() {
|
|
||||||
return this.resource.manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get apiVersion() {
|
|
||||||
return this.resource.apiVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get kind() {
|
|
||||||
return this.resource.kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get name() {
|
|
||||||
return this.resource.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get namespace() {
|
|
||||||
return this.resource.namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get metadata() {
|
|
||||||
return this.resource.metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get spec() {
|
|
||||||
return this.resource.spec;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get data() {
|
|
||||||
return this.resource.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get status() {
|
|
||||||
return this.resource.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public patch = this.resource.patch;
|
|
||||||
public reload = this.resource.load;
|
|
||||||
public delete = this.resource.delete;
|
|
||||||
|
|
||||||
public ensure = async (manifest: T) => {
|
|
||||||
if (isDeepSubset(this.manifest, manifest)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await this.patch(manifest);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
public get ready() {
|
|
||||||
return this.exists;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCondition = (
|
|
||||||
condition: string,
|
|
||||||
): T extends { status?: { conditions?: (infer U)[] } } ? U | undefined : undefined => {
|
|
||||||
const status = this.status as ExpectedAny;
|
|
||||||
return status?.conditions?.find((c: ExpectedAny) => c?.type === condition);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ResourceInstance };
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
import { EventEmitter } from 'eventemitter3';
|
|
||||||
|
|
||||||
import type { Resource } from './resources.ts';
|
|
||||||
import type { ResourceEvents } from './resources.resource.ts';
|
|
||||||
|
|
||||||
type ResourceReferenceEvents<T extends KubernetesObject> = ResourceEvents<T> & {
|
|
||||||
replaced: (options: { previous: Resource<T> | undefined; next: Resource<T> | undefined }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
class ResourceReference<T extends KubernetesObject = KubernetesObject> extends EventEmitter<
|
|
||||||
ResourceReferenceEvents<T>
|
|
||||||
> {
|
|
||||||
#current?: Resource<T>;
|
|
||||||
#updatedEvent: ResourceEvents<T>['updated'];
|
|
||||||
#changedEvent: ResourceEvents<T>['changed'];
|
|
||||||
#changedMetadateEvent: ResourceEvents<T>['changedMetadate'];
|
|
||||||
#changedSpecEvent: ResourceEvents<T>['changedSpec'];
|
|
||||||
#changedStatusEvent: ResourceEvents<T>['changedStatus'];
|
|
||||||
#deletedEvent: ResourceEvents<T>['deleted'];
|
|
||||||
|
|
||||||
constructor(current?: Resource<T>) {
|
|
||||||
super();
|
|
||||||
this.#updatedEvent = this.emit.bind(this, 'updated');
|
|
||||||
this.#changedEvent = this.emit.bind(this, 'changed');
|
|
||||||
this.#changedMetadateEvent = this.emit.bind(this, 'changedMetadate');
|
|
||||||
this.#changedSpecEvent = this.emit.bind(this, 'changedSpec');
|
|
||||||
this.#changedStatusEvent = this.emit.bind(this, 'changedStatus');
|
|
||||||
this.#deletedEvent = this.emit.bind(this, 'deleted');
|
|
||||||
this.current = current;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get services() {
|
|
||||||
return this.#current?.services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get current() {
|
|
||||||
return this.#current;
|
|
||||||
}
|
|
||||||
|
|
||||||
public set current(next: Resource<T> | undefined) {
|
|
||||||
const previous = this.#current;
|
|
||||||
if (next === previous) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.#current) {
|
|
||||||
this.#current.off('updated', this.#updatedEvent);
|
|
||||||
this.#current.off('changed', this.#changedEvent);
|
|
||||||
this.#current.off('changedMetadate', this.#changedMetadateEvent);
|
|
||||||
this.#current.off('changedSpec', this.#changedSpecEvent);
|
|
||||||
this.#current.off('changedStatus', this.#changedStatusEvent);
|
|
||||||
this.#current.off('deleted', this.#deletedEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
next.on('updated', this.#updatedEvent);
|
|
||||||
next.on('changed', this.#changedEvent);
|
|
||||||
next.on('changedMetadate', this.#changedMetadateEvent);
|
|
||||||
next.on('changedSpec', this.#changedSpecEvent);
|
|
||||||
next.on('changedStatus', this.#changedStatusEvent);
|
|
||||||
next.on('deleted', this.#deletedEvent);
|
|
||||||
}
|
|
||||||
this.#current = next;
|
|
||||||
this.emit('replaced', {
|
|
||||||
previous,
|
|
||||||
next,
|
|
||||||
});
|
|
||||||
this.emit('changedStatus', {
|
|
||||||
previous: previous && 'status' in previous ? (previous.status as ExpectedAny) : undefined,
|
|
||||||
next: next && 'status' in next ? (next.status as ExpectedAny) : undefined,
|
|
||||||
});
|
|
||||||
this.emit('changedMetadate', {
|
|
||||||
previous: previous && 'metadata' in previous ? (previous.metadata as ExpectedAny) : undefined,
|
|
||||||
next: next && 'metadata' in next ? (next.metadata as ExpectedAny) : undefined,
|
|
||||||
});
|
|
||||||
this.emit('changedSpec', {
|
|
||||||
previous: previous && 'spec' in previous ? (previous.spec as ExpectedAny) : undefined,
|
|
||||||
next: next && 'spec' in next ? (next.spec as ExpectedAny) : undefined,
|
|
||||||
});
|
|
||||||
this.emit('changed');
|
|
||||||
this.emit('updated');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ResourceReference };
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
import { ApiException, PatchStrategy, V1MicroTime, type KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
import { EventEmitter } from 'eventemitter3';
|
|
||||||
import equal from 'deep-equal';
|
|
||||||
|
|
||||||
import { Services } from '../../utils/service.ts';
|
|
||||||
import { K8sService } from '../k8s/k8s.ts';
|
|
||||||
import { Queue } from '../queue/queue.ts';
|
|
||||||
import { GROUP } from '../../utils/consts.ts';
|
|
||||||
|
|
||||||
import { ResourceService } from './resources.ts';
|
|
||||||
|
|
||||||
type ResourceOptions<T extends KubernetesObject> = {
|
|
||||||
services: Services;
|
|
||||||
manifest?: T;
|
|
||||||
data: {
|
|
||||||
apiVersion: string;
|
|
||||||
kind: string;
|
|
||||||
name: string;
|
|
||||||
namespace?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type UnknownResource = KubernetesObject & {
|
|
||||||
spec: ExpectedAny;
|
|
||||||
data: ExpectedAny;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EventOptions = {
|
|
||||||
reason: string;
|
|
||||||
message: string;
|
|
||||||
action: string;
|
|
||||||
type: 'Normal' | 'Warning' | 'Error';
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResourceEvents<T extends KubernetesObject> = {
|
|
||||||
updated: () => void;
|
|
||||||
deleted: () => void;
|
|
||||||
changed: () => void;
|
|
||||||
changedStatus: (options: {
|
|
||||||
previous: T extends { status: infer K } ? K | undefined : never;
|
|
||||||
next: T extends { status: infer K } ? K | undefined : never;
|
|
||||||
}) => void;
|
|
||||||
changedMetadate: (options: { previous: T['metadata'] | undefined; next: T['metadata'] | undefined }) => void;
|
|
||||||
changedSpec: (options: {
|
|
||||||
previous: T extends { spec: infer K } ? K | undefined : never;
|
|
||||||
next: T extends { spec: infer K } ? K | undefined : never;
|
|
||||||
}) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
class Resource<T extends KubernetesObject = UnknownResource> extends EventEmitter<ResourceEvents<T>> {
|
|
||||||
#options: ResourceOptions<T>;
|
|
||||||
#queue: Queue;
|
|
||||||
|
|
||||||
constructor(options: ResourceOptions<T>) {
|
|
||||||
super();
|
|
||||||
this.#options = options;
|
|
||||||
this.#queue = new Queue({ concurrency: 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
public get services() {
|
|
||||||
return this.#options.services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get specifier() {
|
|
||||||
return this.#options.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get manifest() {
|
|
||||||
return this.#options?.manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
public set manifest(obj: T | undefined) {
|
|
||||||
if (equal(obj, this.manifest)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#options.manifest = obj;
|
|
||||||
const nextManifest = obj || {};
|
|
||||||
const currentManifest = this.manifest || {};
|
|
||||||
const nextStatus = 'status' in nextManifest ? nextManifest.status : undefined;
|
|
||||||
const currentStatus = 'status' in currentManifest ? currentManifest.status : undefined;
|
|
||||||
if (!equal(nextStatus, currentStatus)) {
|
|
||||||
this.emit('changedStatus', {
|
|
||||||
previous: currentStatus as ExpectedAny,
|
|
||||||
next: nextStatus as ExpectedAny,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextSpec = 'spec' in nextManifest ? nextManifest.spec : undefined;
|
|
||||||
const currentSpec = 'spec' in currentManifest ? currentManifest.spec : undefined;
|
|
||||||
if (!equal(nextSpec, currentSpec)) {
|
|
||||||
this.emit('changedSpec', {
|
|
||||||
next: nextSpec as ExpectedAny,
|
|
||||||
previous: currentSpec as ExpectedAny,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextMetadata = 'metadata' in nextManifest ? nextManifest.metadata : undefined;
|
|
||||||
const currentMetadata = 'metadata' in currentManifest ? currentManifest.metadata : undefined;
|
|
||||||
if (!equal(nextMetadata, currentMetadata)) {
|
|
||||||
this.emit('changedMetadate', {
|
|
||||||
next: nextMetadata as ExpectedAny,
|
|
||||||
previous: currentMetadata as ExpectedAny,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('updated');
|
|
||||||
this.emit('changed');
|
|
||||||
}
|
|
||||||
|
|
||||||
public get ref() {
|
|
||||||
if (!this.metadata?.uid) {
|
|
||||||
throw new Error('No uid for resource');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
apiVersion: this.apiVersion,
|
|
||||||
kind: this.kind,
|
|
||||||
name: this.name,
|
|
||||||
uid: this.metadata.uid,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public get exists() {
|
|
||||||
return !!this.manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get apiVersion() {
|
|
||||||
return this.#options.data.apiVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get group() {
|
|
||||||
const [group] = this.apiVersion?.split('/') || [];
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get version() {
|
|
||||||
const [, version] = this.apiVersion?.split('/') || [];
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get kind() {
|
|
||||||
return this.#options.data.kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get metadata() {
|
|
||||||
return this.manifest?.metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get name() {
|
|
||||||
return this.#options.data.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get namespace() {
|
|
||||||
return this.#options.data.namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get spec(): T extends { spec?: infer K } ? K | undefined : never {
|
|
||||||
if (this.manifest && 'spec' in this.manifest) {
|
|
||||||
return this.manifest.spec as ExpectedAny;
|
|
||||||
}
|
|
||||||
return undefined as ExpectedAny;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get data(): T extends { data?: infer K } ? K | undefined : never {
|
|
||||||
if (this.manifest && 'data' in this.manifest) {
|
|
||||||
return this.manifest.data as ExpectedAny;
|
|
||||||
}
|
|
||||||
return undefined as ExpectedAny;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get status(): T extends { status?: infer K } ? K | undefined : never {
|
|
||||||
if (this.manifest && 'status' in this.manifest) {
|
|
||||||
return this.manifest.status as ExpectedAny;
|
|
||||||
}
|
|
||||||
return undefined as ExpectedAny;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get owners() {
|
|
||||||
const { services } = this.#options;
|
|
||||||
const references = this.metadata?.ownerReferences || [];
|
|
||||||
const resourceService = services.get(ResourceService);
|
|
||||||
return references.map((ref) =>
|
|
||||||
resourceService.get({
|
|
||||||
apiVersion: ref.apiVersion,
|
|
||||||
kind: ref.kind,
|
|
||||||
name: ref.name,
|
|
||||||
namespace: this.namespace,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public patch = (patch: T) =>
|
|
||||||
this.#queue.add(async () => {
|
|
||||||
const { services } = this.#options;
|
|
||||||
services.log.debug(`Patching ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`, {
|
|
||||||
specifier: this.specifier,
|
|
||||||
current: this.manifest,
|
|
||||||
patch,
|
|
||||||
});
|
|
||||||
const k8s = services.get(K8sService);
|
|
||||||
const body = {
|
|
||||||
...patch,
|
|
||||||
apiVersion: this.specifier.apiVersion,
|
|
||||||
kind: this.specifier.kind,
|
|
||||||
metadata: {
|
|
||||||
...patch.metadata,
|
|
||||||
name: this.specifier.name,
|
|
||||||
namespace: this.specifier.namespace,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
this.manifest = await k8s.objectsApi.patch(
|
|
||||||
body,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
PatchStrategy.MergePatch,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiException && err.code === 404) {
|
|
||||||
this.manifest = await k8s.objectsApi.create(body);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
public delete = () =>
|
|
||||||
this.#queue.add(async () => {
|
|
||||||
try {
|
|
||||||
const { services } = this.#options;
|
|
||||||
services.log.debug(`Deleting ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`);
|
|
||||||
const k8s = services.get(K8sService);
|
|
||||||
await k8s.objectsApi.delete({
|
|
||||||
apiVersion: this.specifier.apiVersion,
|
|
||||||
kind: this.specifier.kind,
|
|
||||||
metadata: {
|
|
||||||
name: this.specifier.name,
|
|
||||||
namespace: this.specifier.namespace,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.manifest = undefined;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiException && err.code === 404) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
public load = () =>
|
|
||||||
this.#queue.add(async () => {
|
|
||||||
const { services } = this.#options;
|
|
||||||
const k8s = services.get(K8sService);
|
|
||||||
try {
|
|
||||||
const manifest = await k8s.objectsApi.read({
|
|
||||||
apiVersion: this.specifier.apiVersion,
|
|
||||||
kind: this.specifier.kind,
|
|
||||||
metadata: {
|
|
||||||
name: this.specifier.name,
|
|
||||||
namespace: this.specifier.namespace,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.manifest = manifest as T;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiException && err.code === 404) {
|
|
||||||
this.manifest = undefined;
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
public addEvent = (event: EventOptions) =>
|
|
||||||
this.#queue.add(async () => {
|
|
||||||
const { services } = this.#options;
|
|
||||||
const k8sService = services.get(K8sService);
|
|
||||||
|
|
||||||
services.log.debug(`Adding event ${this.apiVersion}/${this.kind}/${this.namespace}/${this.name}`, event);
|
|
||||||
|
|
||||||
await k8sService.eventsApi.createNamespacedEvent({
|
|
||||||
namespace: this.specifier.namespace || 'default',
|
|
||||||
body: {
|
|
||||||
kind: 'Event',
|
|
||||||
metadata: {
|
|
||||||
name: `${this.specifier.name}-${Date.now()}-${Buffer.from(crypto.getRandomValues(new Uint8Array(8))).toString('hex')}`,
|
|
||||||
namespace: this.specifier.namespace,
|
|
||||||
},
|
|
||||||
eventTime: new V1MicroTime(),
|
|
||||||
note: event.message,
|
|
||||||
action: event.action,
|
|
||||||
reason: event.reason,
|
|
||||||
type: event.type,
|
|
||||||
reportingController: GROUP,
|
|
||||||
reportingInstance: this.name,
|
|
||||||
regarding: {
|
|
||||||
apiVersion: this.specifier.apiVersion,
|
|
||||||
resourceVersion: this.metadata?.resourceVersion,
|
|
||||||
kind: this.specifier.kind,
|
|
||||||
name: this.specifier.name,
|
|
||||||
namespace: this.specifier.namespace,
|
|
||||||
uid: this.metadata?.uid,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Resource, type UnknownResource, type ResourceEvents };
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import type { KubernetesObject } from '@kubernetes/client-node';
|
|
||||||
|
|
||||||
import type { Services } from '../../utils/service.ts';
|
|
||||||
|
|
||||||
import { Resource } from './resources.resource.ts';
|
|
||||||
import type { ResourceInstance } from './resources.instance.ts';
|
|
||||||
|
|
||||||
type ResourceGetOptions = {
|
|
||||||
apiVersion: string;
|
|
||||||
kind: string;
|
|
||||||
name: string;
|
|
||||||
namespace?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
class ResourceService {
|
|
||||||
#cache: Resource<ExpectedAny>[] = [];
|
|
||||||
#services: Services;
|
|
||||||
|
|
||||||
constructor(services: Services) {
|
|
||||||
this.#services = services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getInstance = <T extends KubernetesObject, I extends ResourceInstance<T>>(
|
|
||||||
options: ResourceGetOptions,
|
|
||||||
instance: new (resource: Resource<T>) => I,
|
|
||||||
) => {
|
|
||||||
const resource = this.get<T>(options);
|
|
||||||
return new instance(resource);
|
|
||||||
};
|
|
||||||
|
|
||||||
public get = <T extends KubernetesObject>(options: ResourceGetOptions) => {
|
|
||||||
const { apiVersion, kind, name, namespace } = options;
|
|
||||||
let resource = this.#cache.find(
|
|
||||||
(resource) =>
|
|
||||||
resource.specifier.kind === kind &&
|
|
||||||
resource.specifier.apiVersion === apiVersion &&
|
|
||||||
resource.specifier.name === name &&
|
|
||||||
resource.specifier.namespace === namespace,
|
|
||||||
);
|
|
||||||
if (resource) {
|
|
||||||
return resource as Resource<T>;
|
|
||||||
}
|
|
||||||
resource = new Resource({
|
|
||||||
data: options,
|
|
||||||
services: this.#services,
|
|
||||||
});
|
|
||||||
this.#cache.push(resource);
|
|
||||||
return resource as Resource<T>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ResourceInstance } from './resources.instance.ts';
|
|
||||||
export { ResourceReference } from './resources.ref.ts';
|
|
||||||
export { ResourceService, Resource };
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import type { V1Secret } from '@kubernetes/client-node';
|
|
||||||
import type { z, ZodObject } from 'zod';
|
|
||||||
import deepEqual from 'deep-equal';
|
|
||||||
|
|
||||||
import { ResourceService, type Resource } from '../resources/resources.ts';
|
|
||||||
import type { Services } from '../../utils/service.ts';
|
|
||||||
|
|
||||||
type EnsuredSecretOptions<T extends ZodObject> = {
|
|
||||||
services: Services;
|
|
||||||
name: string;
|
|
||||||
namespace: string;
|
|
||||||
schema: T;
|
|
||||||
owner?: ExpectedAny[];
|
|
||||||
generator: (previous?: unknown) => z.infer<T>;
|
|
||||||
validate?: (value: T) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
class EnsuredSecret<T extends ZodObject> {
|
|
||||||
#options: EnsuredSecretOptions<T>;
|
|
||||||
#resource: Resource<V1Secret>;
|
|
||||||
|
|
||||||
constructor(options: EnsuredSecretOptions<T>) {
|
|
||||||
this.#options = options;
|
|
||||||
const { services, name, namespace } = options;
|
|
||||||
const resourceService = services.get(ResourceService);
|
|
||||||
this.#resource = resourceService.get({
|
|
||||||
apiVersion: 'v1',
|
|
||||||
kind: 'Secret',
|
|
||||||
name,
|
|
||||||
namespace,
|
|
||||||
});
|
|
||||||
this.#resource.on('changed', this.#handleChanged);
|
|
||||||
this.#handleChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public get name() {
|
|
||||||
return this.#options.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get namespace() {
|
|
||||||
return this.#options.namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get resource() {
|
|
||||||
return this.#resource;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get value(): z.infer<T> | undefined {
|
|
||||||
if (!this.#resource.data) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(this.#resource.data).map(([name, value]) => [name, Buffer.from(value, 'base64').toString('utf8')]),
|
|
||||||
) as ExpectedAny;
|
|
||||||
}
|
|
||||||
|
|
||||||
public patch = async (value: ExpectedAny) => {
|
|
||||||
const patched = {
|
|
||||||
...this.value,
|
|
||||||
...value,
|
|
||||||
};
|
|
||||||
if (deepEqual(patched, this.value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.resource.patch({
|
|
||||||
data: patched,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
public get isValid() {
|
|
||||||
const { schema, validate } = this.#options;
|
|
||||||
const { success } = schema.safeParse(this.value);
|
|
||||||
if (!success) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (validate) {
|
|
||||||
return validate(this.value as unknown as T);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleChanged = () => {
|
|
||||||
const { generator, owner } = this.#options;
|
|
||||||
if (this.isValid && deepEqual(this.#resource.metadata?.ownerReferences, owner)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = generator();
|
|
||||||
const encodedValues = Object.fromEntries(
|
|
||||||
Object.entries(data).map(([name, value]) => [name, Buffer.from(String(value)).toString('base64')]),
|
|
||||||
);
|
|
||||||
this.#resource.patch({
|
|
||||||
metadata: {
|
|
||||||
ownerReferences: owner,
|
|
||||||
},
|
|
||||||
data: encodedValues,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { EnsuredSecret, type EnsuredSecretOptions };
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { ZodObject } from 'zod';
|
|
||||||
|
|
||||||
import type { Services } from '../../utils/service.ts';
|
|
||||||
|
|
||||||
import { EnsuredSecret, type EnsuredSecretOptions } from './secrets.secret.ts';
|
|
||||||
|
|
||||||
class SecretService {
|
|
||||||
#services: Services;
|
|
||||||
|
|
||||||
constructor(services: Services) {
|
|
||||||
this.#services = services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ensure = <T extends ZodObject>(options: Omit<EnsuredSecretOptions<T>, 'services'>) => {
|
|
||||||
return new EnsuredSecret({
|
|
||||||
...options,
|
|
||||||
services: this.#services,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { SecretService };
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user