feat: add selfmedia access secret distribution

This commit is contained in:
Codex
2026-07-13 23:43:40 +02:00
parent 9833f5fd6b
commit f2b9c7b7b6
4 changed files with 98 additions and 9 deletions
+39
View File
@@ -9,6 +9,7 @@ metadata:
- 300 - 300
- 313 - 313
- 2256 - 2256
- 1964
sources: sources:
root: /root/.unidesk/.state/secrets root: /root/.unidesk/.state/secrets
@@ -120,6 +121,27 @@ sources:
createIfMissing: createIfMissing:
enabled: false enabled: false
externalFiles: externalFiles:
- sourceRef: /root/.unidesk/.env/selfmedia-access.env
type: env
requiredKeys:
- SELFMEDIA_ADMIN_USERNAME
- SELFMEDIA_ADMIN_PASSWORD
- SELFMEDIA_API_KEY
- SELFMEDIA_SESSION_SECRET
createIfMissing:
enabled: true
values:
SELFMEDIA_ADMIN_USERNAME: admin
randomBase64Url:
SELFMEDIA_ADMIN_PASSWORD:
bytes: 32
prefix: smp_
SELFMEDIA_API_KEY:
bytes: 32
prefix: smk_
SELFMEDIA_SESSION_SECRET:
bytes: 32
prefix: sms_
- sourceRef: ~/.env/TOKEN - sourceRef: ~/.env/TOKEN
type: raw-file type: raw-file
required: true required: true
@@ -167,6 +189,23 @@ targets:
enabled: true enabled: true
kubernetesSecrets: kubernetesSecrets:
- name: selfmedia-access
targetId: selfmedia-nc01
secretName: selfmedia-access
type: Opaque
data:
- sourceRef: /root/.unidesk/.env/selfmedia-access.env
sourceKey: SELFMEDIA_ADMIN_USERNAME
targetKey: admin-username
- sourceRef: /root/.unidesk/.env/selfmedia-access.env
sourceKey: SELFMEDIA_ADMIN_PASSWORD
targetKey: admin-password
- sourceRef: /root/.unidesk/.env/selfmedia-access.env
sourceKey: SELFMEDIA_API_KEY
targetKey: api-key
- sourceRef: /root/.unidesk/.env/selfmedia-access.env
sourceKey: SELFMEDIA_SESSION_SECRET
targetKey: session-secret
- name: selfmedia-runtime - name: selfmedia-runtime
targetId: selfmedia-nc01 targetId: selfmedia-nc01
secretName: selfmedia-runtime secretName: selfmedia-runtime
+14
View File
@@ -172,6 +172,20 @@ delivery:
mountPath: /home/codex/.codex/state mountPath: /home/codex/.codex/state
subPath: state subPath: state
secrets: secrets:
access:
sourceRef: /root/.unidesk/.env/selfmedia-access.env
target:
secretName: selfmedia-access
readOnly: true
files:
- targetKey: admin-username
mountPath: /run/secrets/selfmedia-access/admin-username
- targetKey: admin-password
mountPath: /run/secrets/selfmedia-access/admin-password
- targetKey: api-key
mountPath: /run/secrets/selfmedia-access/api-key
- targetKey: session-secret
mountPath: /run/secrets/selfmedia-access/session-secret
runtime: runtime:
sourceRef: ~/.env/TOKEN sourceRef: ~/.env/TOKEN
target: target:
+21 -9
View File
@@ -40,7 +40,7 @@ interface SecretDistributionConfig {
version: number; version: number;
kind: "unidesk-secret-distribution"; kind: "unidesk-secret-distribution";
metadata: { id: string; owner: string; relatedIssues: number[] }; metadata: { id: string; owner: string; relatedIssues: number[] };
sources: { root: string; files: EnvSourceFileConfig[]; externalFiles: RawSourceFileConfig[] }; sources: { root: string; files: EnvSourceFileConfig[]; externalFiles: SourceFileConfig[] };
targets: DistributionTarget[]; targets: DistributionTarget[];
kubernetesSecrets: KubernetesSecretConfig[]; kubernetesSecrets: KubernetesSecretConfig[];
} }
@@ -311,7 +311,7 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig
files: arrayOfRecords(sources.files, `${label}.sources.files`).map((item, index) => parseSourceFile(item, `${label}.sources.files[${index}]`)), files: arrayOfRecords(sources.files, `${label}.sources.files`).map((item, index) => parseSourceFile(item, `${label}.sources.files[${index}]`)),
externalFiles: sources.externalFiles === undefined externalFiles: sources.externalFiles === undefined
? [] ? []
: arrayOfRecords(sources.externalFiles, `${label}.sources.externalFiles`).map((item, index) => parseRawSourceFile(item, `${label}.sources.externalFiles[${index}]`)), : arrayOfRecords(sources.externalFiles, `${label}.sources.externalFiles`).map((item, index) => parseExternalSourceFile(item, `${label}.sources.externalFiles[${index}]`)),
}, },
targets: arrayOfRecords(root.targets, `${label}.targets`).map((item, index) => parseTarget(item, `${label}.targets[${index}]`)), targets: arrayOfRecords(root.targets, `${label}.targets`).map((item, index) => parseTarget(item, `${label}.targets[${index}]`)),
kubernetesSecrets: arrayOfRecords(root.kubernetesSecrets, `${label}.kubernetesSecrets`).map((item, index) => parseKubernetesSecret(item, `${label}.kubernetesSecrets[${index}]`)), kubernetesSecrets: arrayOfRecords(root.kubernetesSecrets, `${label}.kubernetesSecrets`).map((item, index) => parseKubernetesSecret(item, `${label}.kubernetesSecrets[${index}]`)),
@@ -320,13 +320,13 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig
return config; return config;
} }
function parseSourceFile(record: Record<string, unknown>, path: string): EnvSourceFileConfig { function parseSourceFile(record: Record<string, unknown>, path: string, external = false): EnvSourceFileConfig {
const type = stringField(record, "type", path); const type = stringField(record, "type", path);
if (type !== "env") throw new Error(`${path}.type must be env`); if (type !== "env") throw new Error(`${path}.type must be env`);
const createRaw = record.createIfMissing === undefined ? {} : objectField(record, "createIfMissing", path); const createRaw = record.createIfMissing === undefined ? {} : objectField(record, "createIfMissing", path);
const randomBase64UrlRaw = createRaw.randomBase64Url === undefined ? {} : objectField(createRaw, "randomBase64Url", `${path}.createIfMissing`); const randomBase64UrlRaw = createRaw.randomBase64Url === undefined ? {} : objectField(createRaw, "randomBase64Url", `${path}.createIfMissing`);
return { return {
sourceRef: sourceRefField(record, "sourceRef", path), sourceRef: external ? externalSourceRefField(record, "sourceRef", path) : sourceRefField(record, "sourceRef", path),
type, type,
requiredKeys: stringArrayField(record, "requiredKeys", path).map((key, index) => envKeyValue(key, `${path}.requiredKeys[${index}]`)), requiredKeys: stringArrayField(record, "requiredKeys", path).map((key, index) => envKeyValue(key, `${path}.requiredKeys[${index}]`)),
required: true, required: true,
@@ -339,15 +339,16 @@ function parseSourceFile(record: Record<string, unknown>, path: string): EnvSour
}; };
} }
function parseRawSourceFile(record: Record<string, unknown>, path: string): RawSourceFileConfig { function parseExternalSourceFile(record: Record<string, unknown>, path: string): SourceFileConfig {
const type = stringField(record, "type", path); const type = stringField(record, "type", path);
if (type === "env") return parseSourceFile(record, path, true);
if (type !== "raw-file") throw new Error(`${path}.type must be raw-file`); if (type !== "raw-file") throw new Error(`${path}.type must be raw-file`);
const createRaw = objectField(record, "createIfMissing", path); const createRaw = objectField(record, "createIfMissing", path);
const enabled = booleanField(createRaw, "enabled", `${path}.createIfMissing`); const enabled = booleanField(createRaw, "enabled", `${path}.createIfMissing`);
const randomHex = createRaw.randomHex === undefined const randomHex: Record<string, number> = createRaw.randomHex === undefined
? {} ? {}
: { contents: randomByteCount(createRaw.randomHex, `${path}.createIfMissing.randomHex`) }; : { contents: randomByteCount(createRaw.randomHex, `${path}.createIfMissing.randomHex`) };
const randomBase64Url = createRaw.randomBase64Url === undefined const randomBase64Url: Record<string, { bytes: number; prefix: string }> = createRaw.randomBase64Url === undefined
? {} ? {}
: { contents: randomBase64UrlSpec(createRaw.randomBase64Url, `${path}.createIfMissing.randomBase64Url`) }; : { contents: randomBase64UrlSpec(createRaw.randomBase64Url, `${path}.createIfMissing.randomBase64Url`) };
if (createRaw.values !== undefined) throw new Error(`${path}.createIfMissing.values is not allowed for raw-file sources`); if (createRaw.values !== undefined) throw new Error(`${path}.createIfMissing.values is not allowed for raw-file sources`);
@@ -414,7 +415,8 @@ function validateDistributionConfig(config: SecretDistributionConfig): void {
for (const item of secret.data) { for (const item of secret.data) {
const source = sources.get(item.sourceRef); const source = sources.get(item.sourceRef);
if (source === undefined) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} references undeclared sourceRef ${item.sourceRef}`); if (source === undefined) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} references undeclared sourceRef ${item.sourceRef}`);
if (!source.requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps undeclared source key ${item.sourceRef}.${item.sourceKey}`); const requiredKeys: readonly string[] = source.requiredKeys;
if (!requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps undeclared source key ${item.sourceRef}.${item.sourceKey}`);
if (targetKeys.has(item.targetKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps duplicate target key ${item.targetKey}`); if (targetKeys.has(item.targetKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps duplicate target key ${item.targetKey}`);
targetKeys.add(item.targetKey); targetKeys.add(item.targetKey);
} }
@@ -429,7 +431,7 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean,
const root = secretRoot(config); const root = secretRoot(config);
const materials = new Map<string, SourceMaterial>(); const materials = new Map<string, SourceMaterial>();
const entries = allSourceFiles(config).filter((source) => selectedRefs.has(source.sourceRef)).map((source) => { const entries = allSourceFiles(config).filter((source) => selectedRefs.has(source.sourceRef)).map((source) => {
const sourcePath = source.type === "env" ? join(root, source.sourceRef) : externalSourcePath(source.sourceRef); const sourcePath = sourcePathFor(root, source);
const exists = existsSync(sourcePath); const exists = existsSync(sourcePath);
const existingText = exists ? readFileSync(sourcePath, "utf8") : ""; const existingText = exists ? readFileSync(sourcePath, "utf8") : "";
const existing = exists ? source.type === "env" ? parseEnvFile(existingText) : { contents: existingText } : {}; const existing = exists ? source.type === "env" ? parseEnvFile(existingText) : { contents: existingText } : {};
@@ -502,6 +504,8 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean,
presence, presence,
bytes, bytes,
fingerprint, fingerprint,
ownerOnly: true,
fileMode: "0600",
valuesPrinted: false, valuesPrinted: false,
}; };
} }
@@ -517,6 +521,8 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean,
generatedKeys, generatedKeys,
unmaterializedGeneratedKeys, unmaterializedGeneratedKeys,
fingerprint: material.fingerprint, fingerprint: material.fingerprint,
ownerOnly: true,
fileMode: "0600",
valuesPrinted: false, valuesPrinted: false,
}; };
}); });
@@ -967,6 +973,12 @@ function externalSourcePath(sourceRef: string): string {
throw new Error(`external sourceRef ${sourceRef} must be an absolute path or a ~/ home path`); throw new Error(`external sourceRef ${sourceRef} must be an absolute path or a ~/ home path`);
} }
function sourcePathFor(root: string, source: SourceFileConfig): string {
return source.sourceRef.startsWith("~/") || isAbsolute(source.sourceRef)
? externalSourcePath(source.sourceRef)
: join(root, source.sourceRef);
}
function sourceDataKeyField(obj: Record<string, unknown>, key: string, path: string): string { function sourceDataKeyField(obj: Record<string, unknown>, key: string, path: string): string {
const value = stringField(obj, key, path); const value = stringField(obj, key, path);
if (!/^[A-Za-z_][A-Za-z0-9._-]*$/u.test(value)) throw new Error(`${path}.${key} must be a source data key`); if (!/^[A-Za-z_][A-Za-z0-9._-]*$/u.test(value)) throw new Error(`${path}.${key} must be a source data key`);
+24
View File
@@ -158,6 +158,7 @@ function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMe
if (boolean(ci.serviceAccountAutomount, `${path}.ci.serviceAccountAutomount`) !== false) throw new Error(`${path}.ci.serviceAccountAutomount must be false`); if (boolean(ci.serviceAccountAutomount, `${path}.ci.serviceAccountAutomount`) !== false) throw new Error(`${path}.ci.serviceAccountAutomount must be false`);
if (boolean(publicExposure.enabled, `${path}.deployment.publicExposure.enabled`) !== true) throw new Error(`${path}.deployment.publicExposure.enabled must be true`); if (boolean(publicExposure.enabled, `${path}.deployment.publicExposure.enabled`) !== true) throw new Error(`${path}.deployment.publicExposure.enabled must be true`);
if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) !== 4317) throw new Error(`${path}.deployment.publicExposure.hostPort must be 4317`); if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) !== 4317) throw new Error(`${path}.deployment.publicExposure.hostPort must be 4317`);
validateAccessSecret(deployment, `${path}.deployment`);
const gitopsWriteUrl = text(gitops.writeUrl, `${path}.gitops.writeUrl`); const gitopsWriteUrl = text(gitops.writeUrl, `${path}.gitops.writeUrl`);
if (!gitopsWriteUrl.startsWith("http://gitea-http.")) throw new Error(`${path}.gitops.writeUrl must use internal Gitea authority`); if (!gitopsWriteUrl.startsWith("http://gitea-http.")) throw new Error(`${path}.gitops.writeUrl must use internal Gitea authority`);
return { return {
@@ -206,6 +207,29 @@ function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMe
}; };
} }
function validateAccessSecret(deployment: Record<string, unknown>, path: string): void {
const secrets = record(deployment.secrets, `${path}.secrets`);
const access = record(secrets.access, `${path}.secrets.access`);
const target = record(access.target, `${path}.secrets.access.target`);
absolutePath(access.sourceRef, `${path}.secrets.access.sourceRef`);
kubernetesName(target.secretName, `${path}.secrets.access.target.secretName`);
if (boolean(target.readOnly, `${path}.secrets.access.target.readOnly`) !== true) {
throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.readOnly must be true`);
}
if (!Array.isArray(target.files) || target.files.length === 0) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files must be a non-empty array`);
const targetKeys = new Set<string>();
const mountPaths = new Set<string>();
target.files.forEach((value, index) => {
const file = record(value, `${path}.secrets.access.target.files[${index}]`);
const targetKey = text(file.targetKey, `${path}.secrets.access.target.files[${index}].targetKey`);
const mountPath = absolutePath(file.mountPath, `${path}.secrets.access.target.files[${index}].mountPath`);
if (targetKeys.has(targetKey)) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files contains duplicate targetKey ${targetKey}`);
if (mountPaths.has(mountPath)) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files contains duplicate mountPath ${mountPath}`);
targetKeys.add(targetKey);
mountPaths.add(mountPath);
});
}
function parseCutoverTarget(id: string, value: Record<string, unknown>): SelfMediaCutoverTarget { function parseCutoverTarget(id: string, value: Record<string, unknown>): SelfMediaCutoverTarget {
const path = `cutover.targets.${id}`; const path = `cutover.targets.${id}`;
const source = record(value.source, `${path}.source`); const source = record(value.source, `${path}.source`);