Преглед изворни кода

WIP: generate codes by claude

Yuki Takei пре 1 година
родитељ
комит
b8a499c2a5

+ 289 - 0
apps/app/src/server/service/config-manager-2/config-definition.ts

@@ -0,0 +1,289 @@
+interface ConfigDefinition<T> {
+  envVarName: string;
+  defaultValue: T;
+  isSecret?: boolean;
+}
+
+export const CONFIG_KEYS = [
+  // App Settings
+  'app:fileUploadType',
+  'app:useOnlyEnvVarForFileUploadType',
+  'app:plantumlUri',
+  'app:drawioUri',
+  'app:nchanUri',
+  'app:siteUrl',
+  'app:aiEnabled',
+  // Security Settings
+  'security:passport-local:isEnabled',
+  'security:passport-saml:isEnabled',
+  'security:passport-saml:entryPoint',
+  'security:passport-saml:issuer',
+  'security:passport-saml:cert',
+  // GCS Settings
+  'gcs:apiKeyJsonPath',
+  'gcs:bucket',
+  'gcs:uploadNamespace',
+  // Azure Settings
+  'azure:tenantId',
+  'azure:clientId',
+  'azure:clientSecret',
+  'azure:storageAccountName',
+  'azure:storageContainerName',
+  // OpenTelemetry Settings
+  'otel:enabled',
+  'otel:isAppSiteUrlHashed',
+  'otel:serviceInstanceId',
+  // OpenAI Settings
+  'openai:serviceType',
+  'openai:apiKey',
+  'openai:searchAssistantInstructions',
+  // Control Flags for Env Vars
+  'env:useSiteUrlEnvVars',
+  'env:useLocalStrategyEnvVars',
+  'env:useSamlEnvVars',
+  'env:useFileUploadEnvVars',
+  'env:useGcsEnvVars',
+  'env:useAzureEnvVars',
+] as const;
+
+export type ConfigKey = (typeof CONFIG_KEYS)[number];
+
+// Safe accessor object
+
+type ValidateKeyFn = (key: unknown) => asserts key is ConfigKey;
+export const ConfigKeys = {
+  all: CONFIG_KEYS,
+  includes: (key: unknown): key is ConfigKey => CONFIG_KEYS.includes(key as any),
+  validateKey: ((key: unknown): asserts key is ConfigKey => {
+    if (!ConfigKeys.includes(key)) {
+      throw new Error(`Invalid config key: ${String(key)}`);
+    }
+  }) satisfies ValidateKeyFn,
+} as const;
+
+type ConfigDefinitions = {
+  [K in ConfigKey]: ConfigDefinition<unknown>;
+};
+
+export const CONFIG_DEFINITIONS: ConfigDefinitions = {
+  // App Settings
+  'app:fileUploadType': {
+    envVarName: 'FILE_UPLOAD',
+    defaultValue: 'aws',
+  },
+  'app:useOnlyEnvVarForFileUploadType': {
+    envVarName: 'FILE_UPLOAD_USES_ONLY_ENV_VAR_FOR_FILE_UPLOAD_TYPE',
+    defaultValue: false,
+  },
+  'app:plantumlUri': {
+    envVarName: 'PLANTUML_URI',
+    defaultValue: 'https://www.plantuml.com/plantuml',
+  },
+  'app:drawioUri': {
+    envVarName: 'DRAWIO_URI',
+    defaultValue: 'https://embed.diagrams.net/',
+  },
+  'app:nchanUri': {
+    envVarName: 'NCHAN_URI',
+    defaultValue: null,
+  },
+  'app:siteUrl': {
+    envVarName: 'APP_SITE_URL',
+    defaultValue: null,
+  },
+  'app:aiEnabled': {
+    envVarName: 'AI_ENABLED',
+    defaultValue: false,
+  },
+
+  // Security Settings
+  'security:passport-local:isEnabled': {
+    envVarName: 'SECURITY_PASSPORT_LOCAL_ENABLED',
+    defaultValue: true,
+  },
+  'security:passport-saml:isEnabled': {
+    envVarName: 'SECURITY_PASSPORT_SAML_ENABLED',
+    defaultValue: false,
+  },
+  'security:passport-saml:entryPoint': {
+    envVarName: 'SECURITY_PASSPORT_SAML_ENTRY_POINT',
+    defaultValue: '',
+  },
+  'security:passport-saml:issuer': {
+    envVarName: 'SECURITY_PASSPORT_SAML_ISSUER',
+    defaultValue: '',
+  },
+  'security:passport-saml:cert': {
+    envVarName: 'SECURITY_PASSPORT_SAML_CERT',
+    defaultValue: '',
+  },
+
+  // GCS Settings
+  'gcs:apiKeyJsonPath': {
+    envVarName: 'GCS_API_KEY_JSON_PATH',
+    defaultValue: '',
+  },
+  'gcs:bucket': {
+    envVarName: 'GCS_BUCKET',
+    defaultValue: '',
+  },
+  'gcs:uploadNamespace': {
+    envVarName: 'GCS_UPLOAD_NAMESPACE',
+    defaultValue: '',
+  },
+
+  // Azure Settings
+  'azure:tenantId': {
+    envVarName: 'AZURE_TENANT_ID',
+    defaultValue: '',
+  },
+  'azure:clientId': {
+    envVarName: 'AZURE_CLIENT_ID',
+    defaultValue: '',
+  },
+  'azure:clientSecret': {
+    envVarName: 'AZURE_CLIENT_SECRET',
+    defaultValue: '',
+    isSecret: true,
+  },
+  'azure:storageAccountName': {
+    envVarName: 'AZURE_STORAGE_ACCOUNT_NAME',
+    defaultValue: '',
+  },
+  'azure:storageContainerName': {
+    envVarName: 'AZURE_STORAGE_CONTAINER_NAME',
+    defaultValue: '',
+  },
+
+  // OpenTelemetry Settings
+  'otel:enabled': {
+    envVarName: 'OPENTELEMETRY_ENABLED',
+    defaultValue: true,
+  },
+  'otel:isAppSiteUrlHashed': {
+    envVarName: 'OPENTELEMETRY_IS_APP_SITE_URL_HASHED',
+    defaultValue: false,
+  },
+  'otel:serviceInstanceId': {
+    envVarName: 'OPENTELEMETRY_SERVICE_INSTANCE_ID',
+    defaultValue: null,
+  },
+
+  // OpenAI Settings
+  'openai:serviceType': {
+    envVarName: 'OPENAI_SERVICE_TYPE',
+    defaultValue: null,
+  },
+  'openai:apiKey': {
+    envVarName: 'OPENAI_API_KEY',
+    defaultValue: null,
+    isSecret: true,
+  },
+  'openai:searchAssistantInstructions': {
+    envVarName: 'OPENAI_SEARCH_ASSISTANT_INSTRUCTIONS',
+    defaultValue: null,
+  },
+
+  // Control Flags for Env Vars
+  'env:useSiteUrlEnvVars': {
+    envVarName: 'APP_SITE_URL_USES_ONLY_ENV_VARS',
+    defaultValue: false,
+  },
+  'env:useLocalStrategyEnvVars': {
+    envVarName: 'SECURITY_PASSPORT_LOCAL_USES_ONLY_ENV_VARS',
+    defaultValue: false,
+  },
+  'env:useSamlEnvVars': {
+    envVarName: 'SECURITY_PASSPORT_SAML_USES_ONLY_ENV_VARS',
+    defaultValue: false,
+  },
+  'env:useFileUploadEnvVars': {
+    envVarName: 'FILE_UPLOAD_USES_ONLY_ENV_VARS',
+    defaultValue: false,
+  },
+  'env:useGcsEnvVars': {
+    envVarName: 'GCS_USES_ONLY_ENV_VARS',
+    defaultValue: false,
+  },
+  'env:useAzureEnvVars': {
+    envVarName: 'AZURE_USES_ONLY_ENV_VARS',
+    defaultValue: false,
+  },
+};
+
+// Define groups of settings that use only environment variables
+export interface EnvOnlyGroup {
+  controlKey: ConfigKey;
+  targetKeys: ConfigKey[];
+}
+
+export const ENV_ONLY_GROUPS: EnvOnlyGroup[] = [
+  {
+    controlKey: 'env:useSiteUrlEnvVars',
+    targetKeys: ['app:siteUrl'],
+  },
+  {
+    controlKey: 'env:useLocalStrategyEnvVars',
+    targetKeys: ['security:passport-local:isEnabled'],
+  },
+  {
+    controlKey: 'env:useSamlEnvVars',
+    targetKeys: [
+      'security:passport-saml:isEnabled',
+      'security:passport-saml:entryPoint',
+      'security:passport-saml:issuer',
+      'security:passport-saml:cert',
+    ],
+  },
+  {
+    controlKey: 'env:useFileUploadEnvVars',
+    targetKeys: ['app:fileUploadType'],
+  },
+  {
+    controlKey: 'env:useGcsEnvVars',
+    targetKeys: [
+      'gcs:apiKeyJsonPath',
+      'gcs:bucket',
+      'gcs:uploadNamespace',
+    ],
+  },
+  {
+    controlKey: 'env:useAzureEnvVars',
+    targetKeys: [
+      'azure:tenantId',
+      'azure:clientId',
+      'azure:clientSecret',
+      'azure:storageAccountName',
+      'azure:storageContainerName',
+    ],
+  },
+];
+
+export type ConfigSource = 'env' | 'db';
+
+export type ConfigValues = {
+  [K in ConfigKey]: (typeof CONFIG_DEFINITIONS)[K] extends ConfigDefinition<infer T> ? T : never;
+};
+
+export interface RawConfigData {
+  env: Partial<ConfigValues>;
+  db: Partial<ConfigValues>;
+}
+
+export type MergedConfigData = {
+  [K in ConfigKey]: {
+    value: ConfigValues[K];
+    source: ConfigSource;
+  }
+};
+
+// Runtime consistency check
+const validateConfigDefinitions = (): void => {
+  for (const key of CONFIG_KEYS) {
+    if (!(key in CONFIG_DEFINITIONS)) {
+      throw new Error(`Missing config definition for key: ${key}`);
+    }
+  }
+};
+
+validateConfigDefinitions();

+ 66 - 0
apps/app/src/server/service/config-manager-2/config-loader.ts

@@ -0,0 +1,66 @@
+import loggerFactory from '~/utils/logger';
+
+import { Config } from '../../models/config';
+
+import type {
+  ConfigKey,
+  RawConfigData,
+} from './config-definition';
+import {
+  CONFIG_DEFINITIONS,
+} from './config-definition';
+
+
+const logger = loggerFactory('growi:service:ConfigLoader');
+
+export class ConfigLoader {
+
+  /**
+   * Load configuration from environment variables
+   */
+  async loadFromEnv(): Promise<RawConfigData['env']> {
+    const envConfig: RawConfigData['env'] = {};
+
+    for (const [key, metadata] of Object.entries(CONFIG_DEFINITIONS)) {
+      const envValue = process.env[metadata.envVarName];
+      if (envValue === undefined) {
+        envConfig[key as ConfigKey] = metadata.defaultValue;
+      }
+      else {
+        envConfig[key as ConfigKey] = this.parseEnvValue(envValue, typeof metadata.defaultValue);
+      }
+    }
+
+    logger.debug('loadFromEnv', envConfig);
+    return envConfig;
+  }
+
+  /**
+   * Load configuration from the database
+   */
+  async loadFromDB(): Promise<RawConfigData['db']> {
+    const dbConfig: RawConfigData['db'] = {};
+    const docs = await Config.find().exec();
+
+    for (const doc of docs) {
+      dbConfig[doc.key as ConfigKey] = doc.value ? JSON.parse(doc.value) : null;
+    }
+
+    logger.debug('loadFromDB', dbConfig);
+    return dbConfig;
+  }
+
+  private parseEnvValue(value: string, type: string): any {
+    switch (type) {
+      case 'number':
+        return parseInt(value, 10);
+      case 'boolean':
+        return value.toLowerCase() === 'true';
+      case 'string':
+        return value;
+      default:
+        return value;
+    }
+  }
+
+}

+ 228 - 0
apps/app/src/server/service/config-manager-2/config-manager.ts

@@ -0,0 +1,228 @@
+import loggerFactory from '~/utils/logger';
+
+import { Config } from '../../models/config';
+import S2sMessage from '../../models/vo/s2s-message';
+import type { S2sMessagingService } from '../s2s-messaging/base';
+
+import {
+  ConfigKeys,
+  CONFIG_DEFINITIONS,
+  ENV_ONLY_GROUPS,
+} from './config-definition';
+import type {
+  ConfigKey,
+  ConfigValues,
+  MergedConfigData,
+  RawConfigData,
+} from './config-definition';
+import { ConfigLoader } from './config-loader';
+
+const logger = loggerFactory('growi:service:ConfigManager');
+
+
+type ConfigUpdates<K extends ConfigKey> = Partial<{ [P in K]: ConfigValues[P] }>;
+
+export class ConfigManager {
+
+  private configLoader = new ConfigLoader();
+
+  private s2sMessagingService?: S2sMessagingService;
+
+  private rawConfig?: RawConfigData;
+
+  private mergedConfig?: MergedConfigData;
+
+  private lastLoadedAt?: Date;
+
+  private keyToGroupMap: Map<ConfigKey, ConfigKey> = new Map();
+
+  constructor() {
+    this.initKeyToGroupMap();
+    this.init();
+  }
+
+  private initKeyToGroupMap() {
+    for (const group of ENV_ONLY_GROUPS) {
+      for (const targetKey of group.targetKeys) {
+        this.keyToGroupMap.set(targetKey, group.controlKey);
+      }
+    }
+  }
+
+  private async init() {
+    await this.loadConfigs();
+  }
+
+  private shouldUseEnvOnly(key: ConfigKey): boolean {
+    const controlKey = this.keyToGroupMap.get(key);
+    if (!controlKey) {
+      return false;
+    }
+    return this.getConfigValue(controlKey) === true;
+  }
+
+  async loadConfigs(): Promise<void> {
+    this.rawConfig = {
+      env: await this.configLoader.loadFromEnv(),
+      db: await this.configLoader.loadFromDB(),
+    };
+
+    this.mergedConfig = this.mergeConfigs(this.rawConfig);
+    this.lastLoadedAt = new Date();
+  }
+
+  /**
+   * Method to get configuration values with type inference
+   */
+  getConfig<K extends ConfigKey>(key: K): ConfigValues[K] {
+    if (!this.mergedConfig || !this.rawConfig) {
+      throw new Error('Config is not loaded');
+    }
+
+    // Since key is already constrained by K extends ConfigKey,
+    // additional type checks are unnecessary
+    if (this.shouldUseEnvOnly(key)) {
+      const metadata = CONFIG_DEFINITIONS[key];
+      return (this.rawConfig.env[key] ?? metadata.defaultValue) as ConfigValues[K];
+    }
+
+    return this.mergedConfig[key].value;
+  }
+
+  // Instead, prepare a validation method for public methods that are not type-safe
+  validateConfigKey(key: unknown): asserts key is ConfigKey {
+    if (!ConfigKeys.includes(key)) {
+      throw new Error(`Invalid config key: ${String(key)}`);
+    }
+  }
+
+  /**
+   * Method for receiving any string as a key
+   */
+  getConfigByKey(key: string): unknown {
+    this.validateConfigKey(key);
+    return this.getConfig(key);
+  }
+
+  /**
+   * Get raw config
+   */
+  getRawConfig(): RawConfigData | undefined {
+    return this.rawConfig;
+  }
+
+  /**
+   * Get merged config
+   */
+  getMergedConfig(): MergedConfigData | undefined {
+    return this.mergedConfig;
+  }
+
+  /**
+   * Type-safe configuration update
+   */
+  async updateConfig<K extends ConfigKey>(
+      key: K,
+      value: ConfigValues[K],
+  ): Promise<void> {
+    await Config.updateOne(
+      { key },
+      { value: JSON.stringify(value) },
+      { upsert: true },
+    );
+
+    await this.loadConfigs();
+    await this.publishUpdateMessage();
+  }
+
+  /**
+   * Bulk update of multiple type-safe configurations
+   */
+  async updateConfigs<K extends ConfigKey>(
+      updates: ConfigUpdates<K>,
+  ): Promise<void> {
+    const operations = Object.entries(updates).map(([key, value]) => ({
+      updateOne: {
+        filter: { key },
+        update: { value: JSON.stringify(value) },
+        upsert: true,
+      },
+    }));
+
+    await Config.bulkWrite(operations);
+    await this.loadConfigs();
+    await this.publishUpdateMessage();
+  }
+
+  /**
+   * Update configuration that is not type-safe (accepts string keys)
+   */
+  async updateConfigByKey(
+      key: string,
+      value: unknown,
+  ): Promise<void> {
+    this.validateConfigKey(key);
+    await this.updateConfig(key, value as any);
+  }
+
+  /**
+   * Bulk update of multiple configurations that are not type-safe (accepts string keys)
+   */
+  async updateConfigsByKey(
+      updates: Record<string, unknown>,
+  ): Promise<void> {
+    // Validate all keys
+    for (const key of Object.keys(updates)) {
+      this.validateConfigKey(key);
+    }
+
+    await this.updateConfigs(updates as any);
+  }
+
+  /**
+   * Get value from merged configuration (for condition checks)
+   */
+  private getConfigValue<K extends ConfigKey>(key: K): ConfigValues[K] {
+    if (!this.mergedConfig) {
+      throw new Error('Config is not loaded');
+    }
+    return this.mergedConfig[key].value;
+  }
+
+  private mergeConfigs(raw: RawConfigData): MergedConfigData {
+    const merged = {} as MergedConfigData;
+
+    for (const key of ConfigKeys.all) {
+      const metadata = CONFIG_DEFINITIONS[key];
+      const dbValue = raw.db[key];
+      const envValue = raw.env[key];
+
+      merged[key] = {
+        value: dbValue ?? envValue ?? metadata.defaultValue,
+        source: dbValue != null ? 'db' : 'env',
+      };
+    }
+
+    return merged;
+  }
+
+  private async publishUpdateMessage(): Promise<void> {
+    if (!this.s2sMessagingService) return;
+
+    try {
+      const message = new S2sMessage('configUpdated', { updatedAt: new Date() });
+      await this.s2sMessagingService.publish(message);
+    }
+    catch (e) {
+      logger.error('Failed to publish update message:', e);
+    }
+  }
+
+  setS2sMessagingService(service: S2sMessagingService): void {
+    this.s2sMessagingService = service;
+  }
+
+}
+
+// Export singleton instance
+export const configManager = new ConfigManager();