diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8cd09482a..3d8936cde 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,9 +9,9 @@ name: Build test push release on: push: branches: - - '**' + - "**" tags-ignore: - - '*' + - "*" env: COMMIT_MSG: ${{ github.event.head_commit.message }} CACHE_REGISTRY: ghcr.io @@ -35,11 +35,21 @@ jobs: echo "TAG=$tag" >> $GITHUB_ENV - name: Checkout uses: actions/checkout@v6 + - name: Fetch values-schema.yaml from apl-core + run: | + # Try matching branch first, fall back to main + curl -sL -f -H "Authorization: token ${{ env.BOT_TOKEN }}" \ + "https://raw.githubusercontent.com/linode/apl-core/${{ env.TAG }}/values-schema.yaml" \ + -o src/values-schema.yaml 2>/dev/null \ + || curl -sL -f -H "Authorization: token ${{ env.BOT_TOKEN }}" \ + "https://raw.githubusercontent.com/linode/apl-core/main/values-schema.yaml" \ + -o src/values-schema.yaml + echo "Schema fetched for branch: ${{ env.TAG }} (with main fallback)" - name: CI tests, image build and push tag for main or branch uses: whoan/docker-build-with-cache-action@v8 with: username: ${{ env.BOT_USERNAME }} - password: '${{ env.BOT_TOKEN }}' + password: "${{ env.BOT_TOKEN }}" registry: ${{ env.CACHE_REGISTRY }} image_name: ${{ env.CACHE_REPO }} image_tag: ${{ env.TAG }} diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 4c4949f7e..fadbe5c8d 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -1,8 +1,8 @@ -name: 'postman' +name: "postman" on: pull_request: branches: - - '*' + - "*" workflow_dispatch: jobs: postman: @@ -57,6 +57,8 @@ jobs: npm install npm run compile NODE_PATH="/usr/local/lib/node_modules" npm run server > $GITHUB_WORKSPACE/core.log 2>&1 & + - name: Sync values schema from apl-core + run: cp apl-core/values-schema.yaml src/values-schema.yaml - name: Start api run: | npm install diff --git a/.gitignore b/.gitignore index f536723cd..87164783e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ kms.json* /vendors/client/ /src/generated-* +/src/values-schema.yaml secrets.*.yaml.dec #intelij diff --git a/package-lock.json b/package-lock.json index 9b96f9083..6f24f1c79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@casl/ability": "6.8.0", "@kubernetes/client-node": "1.4.0", "@linode/api-v4": "0.157.0", + "@linode/kubeseal-encrypt": "^1.0.1", "@types/json-schema": "7.0.15", "@types/jsonwebtoken": "9.0.10", "async-retry": "^1.3.3", @@ -4729,6 +4730,15 @@ "node": ">= 10" } }, + "node_modules/@linode/kubeseal-encrypt": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@linode/kubeseal-encrypt/-/kubeseal-encrypt-1.0.1.tgz", + "integrity": "sha512-NTiPFA8jAcfotB6dezE4i/kd3TEt75IsI1AqpGOWWPDCUiu4pjWwznDcGq5z8jnyx27UW/SKH5UUJM5JN4uxqw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@linode/validation": { "version": "0.82.0", "resolved": "https://registry.npmjs.org/@linode/validation/-/validation-0.82.0.tgz", diff --git a/package.json b/package.json index d7964bbf8..12f58ad2b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@casl/ability": "6.8.0", "@kubernetes/client-node": "1.4.0", "@linode/api-v4": "0.157.0", + "@linode/kubeseal-encrypt": "^1.0.1", "@types/json-schema": "7.0.15", "@types/jsonwebtoken": "9.0.10", "axios": "1.13.6", @@ -53,8 +54,8 @@ "@eslint/compat": "2.0.3", "@redocly/openapi-cli": "1.0.0-beta.95", "@semantic-release/changelog": "6.0.3", - "@types/async-retry": "^1.4.8", "@semantic-release/git": "10.0.1", + "@types/async-retry": "^1.4.8", "@types/debug": "^4.1.12", "@types/expect": "24.3.2", "@types/express": "^5.0.6", @@ -135,6 +136,7 @@ "clean": "rm -rf dist >/dev/null", "cz": "git-cz", "cz:retry": "git-cz --retry", + "predev": "npm run schema:sync", "dev": "run-p watch dev:node", "dev:node": "tsx watch --env-file=.env --inspect=4321 src/app.ts", "lint": "run-p types lint:ts", @@ -144,6 +146,7 @@ "postinstall": "npm run build:models", "pre-release:client": "npm version prerelease --preid rc --no-commit-hooks --no-git-tag-version && bin/release-client.sh", "release": "standard-version", + "schema:sync": "APL_CORE_PATH=${APL_CORE_PATH:-../apl-core} && cp \"$APL_CORE_PATH/values-schema.yaml\" src/values-schema.yaml && echo \"Schema synced from $APL_CORE_PATH\"", "release:bump:minor": "standard-version --skip.changelog true --release-as minor", "release:client": "bin/release-client.sh", "start": "node dist/src/app.js", diff --git a/src/api.authz.test.ts b/src/api.authz.test.ts index 452416477..c8cfa22a1 100644 --- a/src/api.authz.test.ts +++ b/src/api.authz.test.ts @@ -4,13 +4,13 @@ import { initApp, loadSpec } from 'src/app' import getToken from 'src/fixtures/jwt' import OtomiStack from 'src/otomi-stack' import request from 'supertest' +import TestAgent from 'supertest/lib/agent' import { HttpError } from './error' +import { FileStore } from './fileStore/file-store' import { Git } from './git' import { getSessionStack } from './middleware' import { App, CodeRepo, Netpol, SealedSecret } from './otomi-models' import * as getValuesSchemaModule from './utils' -import TestAgent from 'supertest/lib/agent' -import { FileStore } from './fileStore/file-store' const platformAdminToken = getToken(['platform-admin']) const teamAdminToken = getToken(['team-admin', 'team-team1']) @@ -20,7 +20,29 @@ const userToken = getToken([]) const teamId = 'team1' const otherTeamId = 'team2' -jest.mock('./k8s_operations') +jest.mock('./k8s_operations', () => { + const original = jest.requireActual('./k8s_operations') + return { + ...original, + apply: jest.fn(), + checkPodExists: jest.fn(), + getCloudttyActiveTime: jest.fn(), + getKubernetesVersion: jest.fn().mockResolvedValue('x.x.x'), + getSecretValues: jest.fn().mockResolvedValue({ adminPassword: 'test-admin-password' }), + getTeamSecretsFromK8s: jest.fn().mockResolvedValue([]), + getUserSecretFromK8s: jest.fn().mockResolvedValue(undefined), + listUserSecretsFromK8s: jest.fn().mockResolvedValue([]), + k8sdelete: jest.fn(), + watchPodUntilRunning: jest.fn(), + getSealedSecretsCertificate: jest.fn().mockResolvedValue(''), + getSealedSecretSyncedStatus: jest.fn().mockResolvedValue('NotFound'), + getSealedSecretStatus: jest.fn().mockResolvedValue('NotFound'), + getSealedSecretsKeys: jest.fn().mockResolvedValue({}), + getWorkloadStatus: jest.fn().mockResolvedValue('NotFound'), + getBuildStatus: jest.fn().mockResolvedValue('NotFound'), + getServiceStatus: jest.fn().mockResolvedValue('NotFound'), + } +}) jest.mock('./utils/sealedSecretUtils') beforeAll(async () => { jest.spyOn(console, 'log').mockImplementation(() => {}) @@ -92,6 +114,7 @@ describe('API authz tests', () => { beforeEach(() => { jest.spyOn(otomiStack, 'createTeam').mockResolvedValue({ name: 'team', resourceQuota: [] }) + jest.spyOn(getValuesSchemaModule, 'getValuesSchema').mockResolvedValue({}) }) afterEach(() => { diff --git a/src/api/v1/apps.ts b/src/api/v1/apps.ts index 0685f562f..85604d6a0 100644 --- a/src/api/v1/apps.ts +++ b/src/api/v1/apps.ts @@ -9,7 +9,7 @@ const debug = Debug('otomi:api:v1:apps') * Get all apps across all teams * Returns list of all apps with their ids and enabled status */ -export const getApps = (req: OpenApiRequestExt, res: Response): void => { +export const getApps = async (req: OpenApiRequestExt, res: Response): Promise => { debug('getAllApps') - res.json(req.otomi.getApps()) + res.json(await req.otomi.getApps()) } diff --git a/src/api/v1/apps/{teamId}.ts b/src/api/v1/apps/{teamId}.ts index 1fd7d3f2d..ba268d12f 100644 --- a/src/api/v1/apps/{teamId}.ts +++ b/src/api/v1/apps/{teamId}.ts @@ -9,10 +9,10 @@ const debug = Debug('otomi:api:v1:apps') * Get apps for a team * Returns list of team apps with their ids and enabled status */ -export const getTeamApps = (req: OpenApiRequestExt, res: Response): void => { +export const getTeamApps = async (req: OpenApiRequestExt, res: Response): Promise => { const { teamId } = req.params debug('getTeamApps', teamId) - res.json(req.otomi.getTeamApps(teamId)) + res.json(await req.otomi.getTeamApps(teamId)) } /** diff --git a/src/api/v1/apps/{teamId}/{appId}.ts b/src/api/v1/apps/{teamId}/{appId}.ts index c8c52b582..5487dab9f 100644 --- a/src/api/v1/apps/{teamId}/{appId}.ts +++ b/src/api/v1/apps/{teamId}/{appId}.ts @@ -5,9 +5,9 @@ import { App, OpenApiRequestExt } from 'src/otomi-models' * GET /v1/apps/{teamId}/{appId} * Get a specific team app */ -export const getTeamApp = (req: OpenApiRequestExt, res: Response): void => { +export const getTeamApp = async (req: OpenApiRequestExt, res: Response): Promise => { const { teamId, appId } = req.params - res.json(req.otomi.getTeamApp(teamId, appId)) + res.json(await req.otomi.getTeamApp(teamId, appId)) } /** diff --git a/src/api/v1/dashboard.ts b/src/api/v1/dashboard.ts index d6db0d9df..f280f16df 100644 --- a/src/api/v1/dashboard.ts +++ b/src/api/v1/dashboard.ts @@ -8,9 +8,9 @@ const debug = Debug('otomi:api:v1:dashboard') * GET /v1/dashboard * Get dashboard information */ -export const getDashboard = (req: OpenApiRequestExt, res: Response): void => { +export const getDashboard = async (req: OpenApiRequestExt, res: Response): Promise => { const { teamId } = req.query debug(`getDashboard(${teamId})`) - const v = req.otomi.getDashboard(teamId as string) + const v = await req.otomi.getDashboard(teamId as string) res.json(v) } diff --git a/src/api/v1/services.ts b/src/api/v1/services.ts index d5847d210..0ece157f5 100644 --- a/src/api/v1/services.ts +++ b/src/api/v1/services.ts @@ -8,8 +8,8 @@ const debug = Debug('otomi:api:v1:services') * GET /v1/services * Get all services running on the cluster */ -export const getAllServices = (req: OpenApiRequestExt, res: Response): void => { +export const getAllServices = async (req: OpenApiRequestExt, res: Response): Promise => { debug('getAllServices') - const v = req.otomi.getAllServices() + const v = await req.otomi.getAllServices() res.json(v) } diff --git a/src/api/v1/settings.ts b/src/api/v1/settings.ts index 1e85726ef..4372bf88e 100644 --- a/src/api/v1/settings.ts +++ b/src/api/v1/settings.ts @@ -1,6 +1,5 @@ import Debug from 'debug' import { Response } from 'express' -import { omit } from 'lodash' import { OpenApiRequestExt } from 'src/otomi-models' const debug = Debug('otomi:api:v1:settings') @@ -9,15 +8,11 @@ const debug = Debug('otomi:api:v1:settings') * GET /v1/settings * Get settings (optionally filtered by IDs) */ -export const getSettings = (req: OpenApiRequestExt, res: Response): void => { +export const getSettings = async (req: OpenApiRequestExt, res: Response): Promise => { const { ids } = req.query // Handle comma-separated string or array const idsArray = ids ? (typeof ids === 'string' ? ids.split(',') : (ids as string[])) : undefined debug(`getSettings(${idsArray})`) - const v = req.otomi.getSettings(idsArray) - if (v?.otomi) { - res.json(omit(v, ['otomi.adminPassword', 'otomi.git.password'])) - } else { - res.json(v) - } + const v = await req.otomi.getSettings(idsArray) + res.json(v) } diff --git a/src/api/v1/settings/{settingId}.ts b/src/api/v1/settings/{settingId}.ts index 8551022c0..a804ba47e 100644 --- a/src/api/v1/settings/{settingId}.ts +++ b/src/api/v1/settings/{settingId}.ts @@ -1,7 +1,6 @@ import Debug from 'debug' import { Response } from 'express' import { OpenApiRequestExt, Settings } from 'src/otomi-models' -import { omit } from 'lodash' const debug = Debug('otomi:api:v1:settings') @@ -14,9 +13,5 @@ export const editSettings = async (req: OpenApiRequestExt, res: Response): Promi const ids = Object.keys(req.body as Settings) debug(`editSettings(${ids.join(',')})`) const v = await req.otomi.editSettings(req.body as Settings, settingId) - if (v?.otomi) { - res.json(omit(v, ['otomi.adminPassword', 'otomi.git.password'])) - } else { - res.json(v) - } + res.json(v) } diff --git a/src/api/v1/settingsInfo.ts b/src/api/v1/settingsInfo.ts index c7549b9ed..2eef47548 100644 --- a/src/api/v1/settingsInfo.ts +++ b/src/api/v1/settingsInfo.ts @@ -8,7 +8,7 @@ const debug = Debug('otomi:api:v1:settingsinfo') * GET /v1/settingsInfo * Get settings info */ -export const getSettingsInfo = (req: OpenApiRequestExt, res: Response): void => { +export const getSettingsInfo = async (req: OpenApiRequestExt, res: Response): Promise => { debug('getSettingsInfo') - res.json(req.otomi.getSettingsInfo()) + res.json(await req.otomi.getSettingsInfo()) } diff --git a/src/api/v1/teams/{teamId}/services.ts b/src/api/v1/teams/{teamId}/services.ts index 96d43b932..6113e92b1 100644 --- a/src/api/v1/teams/{teamId}/services.ts +++ b/src/api/v1/teams/{teamId}/services.ts @@ -8,10 +8,10 @@ const debug = Debug('otomi:api:v1:teams:services') * GET /v1/teams/{teamId}/services * Get services from a given team */ -export const getTeamServices = (req: OpenApiRequestExt, res: Response): void => { +export const getTeamServices = async (req: OpenApiRequestExt, res: Response): Promise => { const { teamId } = req.params debug(`getTeamServices(${teamId})`) - const v = req.otomi.getTeamServices(teamId) + const v = await req.otomi.getTeamServices(teamId) res.json(v) } diff --git a/src/api/v1/teams/{teamId}/services/{serviceName}.ts b/src/api/v1/teams/{teamId}/services/{serviceName}.ts index 496842cff..d8e55d049 100644 --- a/src/api/v1/teams/{teamId}/services/{serviceName}.ts +++ b/src/api/v1/teams/{teamId}/services/{serviceName}.ts @@ -8,10 +8,10 @@ const debug = Debug('otomi:api:v1:teams:services') * GET /v1/teams/{teamId}/services/{serviceName} * Get a specific service */ -export const getService = (req: OpenApiRequestExt, res: Response): void => { +export const getService = async (req: OpenApiRequestExt, res: Response): Promise => { const { teamId, serviceName } = req.params debug(`getService(${serviceName})`) - const data = req.otomi.getService(decodeURIComponent(teamId), decodeURIComponent(serviceName)) + const data = await req.otomi.getService(decodeURIComponent(teamId), decodeURIComponent(serviceName)) res.json(data) } diff --git a/src/api/v1/users.ts b/src/api/v1/users.ts index 7cd84e2e0..e824863a1 100644 --- a/src/api/v1/users.ts +++ b/src/api/v1/users.ts @@ -8,9 +8,9 @@ const debug = Debug('otomi:api:v1:users') * GET /v1/users * Get all users */ -export const getAllUsers = (req: OpenApiRequestExt, res: Response): void => { +export const getAllUsers = async (req: OpenApiRequestExt, res: Response): Promise => { debug('getAllUsers') - const v = req.otomi.getAllUsers(req.user) + const v = await req.otomi.getAllUsers(req.user) res.json(v) } diff --git a/src/api/v1/users/{userId}.ts b/src/api/v1/users/{userId}.ts index 3b142f970..83506192e 100644 --- a/src/api/v1/users/{userId}.ts +++ b/src/api/v1/users/{userId}.ts @@ -8,10 +8,10 @@ const debug = Debug('otomi:api:v1:users') * GET /v1/users/{userId} * Get a specific user */ -export const getUser = (req: OpenApiRequestExt, res: Response): void => { +export const getUser = async (req: OpenApiRequestExt, res: Response): Promise => { const { userId } = req.params debug(`getUser(${userId})`) - const data = req.otomi.getUser(decodeURIComponent(userId), req.user) + const data = await req.otomi.getUser(decodeURIComponent(userId), req.user) res.json(data) } diff --git a/src/app.ts b/src/app.ts index f6ec68dab..88c928c2c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,7 +22,7 @@ import { apiRateLimiter, authRateLimiter } from 'src/middleware/rate-limit' import { setMockIdx } from 'src/mocks' import { AplResponseObject, OpenAPIDoc, Schema, SealedSecretManifestResponse } from 'src/otomi-models' import { default as OtomiStack } from 'src/otomi-stack' -import { extract, getPaths, getValuesSchema } from 'src/utils' +import { getValuesSchema } from 'src/utils' import { CATALOG_CACHE_REFRESH_INTERVAL_MS, CHECK_LATEST_COMMIT_INTERVAL, @@ -48,7 +48,6 @@ debug('NODE_ENV: ', process.env.NODE_ENV) type OtomiSpec = { spec: OpenAPIDoc - secretPaths: string[] valuesSchema: Record } @@ -84,7 +83,7 @@ const resourceStatus = async (errorSet) => { debug('Values are not loaded yet') return } - const { cluster } = otomiStack.getSettings(['cluster']) + const { cluster } = await otomiStack.getSettings(['cluster']) const domainSuffix = cluster?.domainSuffix const resources: Record = { workloads: otomiStack.getAllAplWorkloads(), @@ -126,17 +125,11 @@ export const loadSpec = async (): Promise => { debug(`Loading api spec from: ${openApiPath}`) const spec = (await $parser.parse(openApiPath)) as OpenAPIDoc const valuesSchema = await getValuesSchema() - const secrets = extract(valuesSchema, (o, i) => i === 'x-secret') - const secretPaths = getPaths(secrets) - otomiSpec = { spec, secretPaths, valuesSchema } + otomiSpec = { spec, valuesSchema } } export const getSpec = (): OtomiSpec => { return otomiSpec } -export function getSecretPaths(): string[] { - const { secretPaths } = getSpec() - return secretPaths -} export const getAppSchema = (appId: string): Schema => { let id: string = appId if (appId.startsWith('ingress-nginx')) id = 'ingress-nginx-platform' diff --git a/src/fileStore/file-map.ts b/src/fileStore/file-map.ts index b7417e506..da2c4e1d6 100644 --- a/src/fileStore/file-map.ts +++ b/src/fileStore/file-map.ts @@ -14,7 +14,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplApp', { kind: 'AplApp', envDir, - pathGlob: `${envDir}/env/apps/*.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/apps/*.yaml`, pathTemplate: 'env/apps/{name}.yaml', name: 'apps', }) @@ -22,7 +22,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplAlertSet', { kind: 'AplAlertSet', envDir, - pathGlob: `${envDir}/env/settings/*alerts.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/settings/*alerts.yaml`, pathTemplate: 'env/settings/alerts.yaml', name: 'alerts', }) @@ -30,7 +30,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplCluster', { kind: 'AplCluster', envDir, - pathGlob: `${envDir}/env/settings/cluster.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/settings/cluster.yaml`, pathTemplate: 'env/settings/cluster.yaml', name: 'cluster', }) @@ -38,7 +38,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplDatabase', { kind: 'AplDatabase', envDir, - pathGlob: `${envDir}/env/databases/*.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/databases/*.yaml`, pathTemplate: 'env/databases/{name}.yaml', name: 'databases', }) @@ -46,7 +46,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplDns', { kind: 'AplDns', envDir, - pathGlob: `${envDir}/env/settings/*dns.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/settings/*dns.yaml`, pathTemplate: 'env/settings/dns.yaml', name: 'dns', }) @@ -62,7 +62,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplKms', { kind: 'AplKms', envDir, - pathGlob: `${envDir}/env/settings/*kms.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/settings/*kms.yaml`, pathTemplate: 'env/settings/kms.yaml', name: 'kms', }) @@ -70,7 +70,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplObjectStorage', { kind: 'AplObjectStorage', envDir, - pathGlob: `${envDir}/env/settings/*obj.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/settings/*obj.yaml`, pathTemplate: 'env/settings/obj.yaml', name: 'obj', }) @@ -78,7 +78,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplIdentityProvider', { kind: 'AplIdentityProvider', envDir, - pathGlob: `${envDir}/env/settings/*oidc.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/settings/*oidc.yaml`, pathTemplate: 'env/settings/oidc.yaml', name: 'oidc', }) @@ -86,7 +86,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplCapabilitySet', { kind: 'AplCapabilitySet', envDir, - pathGlob: `${envDir}/env/settings/*otomi.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/settings/*otomi.yaml`, pathTemplate: 'env/settings/otomi.yaml', name: 'otomi', }) @@ -94,7 +94,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplCatalog', { kind: 'AplCatalog', envDir, - pathGlob: `${envDir}/env/catalogs/*.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/catalogs/*.yaml`, pathTemplate: 'env/catalogs/{name}.yaml', name: 'catalogs', }) @@ -102,7 +102,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplBackupCollection', { kind: 'AplBackupCollection', envDir, - pathGlob: `${envDir}/env/settings/*platformBackups.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/settings/*platformBackups.yaml`, pathTemplate: 'env/settings/platformBackups.yaml', name: 'platformBackups', }) @@ -110,7 +110,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplSmtp', { kind: 'AplSmtp', envDir, - pathGlob: `${envDir}/env/settings/*smtp.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/settings/*smtp.yaml`, pathTemplate: 'env/settings/smtp.yaml', name: 'smtp', }) @@ -118,7 +118,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplUser', { kind: 'AplUser', envDir, - pathGlob: `${envDir}/env/users/*.{yaml,yaml.dec}`, + pathGlob: `${envDir}/env/users/*.yaml`, pathTemplate: 'env/users/{name}.yaml', name: 'users', }) @@ -215,7 +215,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplTeamSettingSet', { kind: 'AplTeamSettingSet', envDir, - pathGlob: `${envDir}/env/teams/*/*settings{.yaml,.yaml.dec}`, + pathGlob: `${envDir}/env/teams/*/*settings.yaml`, pathTemplate: 'env/teams/{teamId}/settings.yaml', name: 'settings', }) @@ -223,7 +223,7 @@ export function getFileMaps(envDir: string): Map { maps.set('AplTeamTool', { kind: 'AplTeamTool', envDir, - pathGlob: `${envDir}/env/teams/*/*apps{.yaml,.yaml.dec}`, + pathGlob: `${envDir}/env/teams/*/*apps.yaml`, pathTemplate: 'env/teams/{teamId}/apps.yaml', name: 'apps', }) diff --git a/src/fileStore/file-store.ts b/src/fileStore/file-store.ts index ba6669f37..339539a49 100644 --- a/src/fileStore/file-store.ts +++ b/src/fileStore/file-store.ts @@ -36,10 +36,6 @@ export async function writeFileToDisk(repoPath: string, relativePath: string, co await writeFile(fullPath, yamlContent, 'utf8') } -function hasDecryptedFile(filePath: string, fileList: string[]): boolean { - return fileList.includes(`${filePath}.dec`) -} - function shouldSkipValidation(filePath: string): boolean { return filePath.includes('/sealedsecrets/') || filePath.includes('/workloadValues/') } @@ -66,15 +62,13 @@ export class FileStore { }), ) - const filesToLoad = fileMapResults.flatMap((files) => - files.filter((filePath) => !hasDecryptedFile(filePath, files)), - ) + const filesToLoad = fileMapResults.flat() await Promise.all( filesToLoad.map(async (filePath) => { try { const rawContent = isRawContent(filePath) ? await loadRawYaml(filePath) : await loadYaml(filePath) - const relativePath = path.relative(envDir, filePath).replace(/\.dec$/, '') + const relativePath = path.relative(envDir, filePath) // Skip validation for specific file paths if (shouldSkipValidation(filePath)) { @@ -102,24 +96,27 @@ export class FileStore { }), ) - // PASS 2: Merge secret files into main files - for (const [filePath, content] of allFiles.entries()) { - if (filePath.includes('/secrets.')) { - // This is a secret file - find its main file - const mainFilePath = filePath.replace('/secrets.', '/') - const mainContent = allFiles.get(mainFilePath) - - if (mainContent) { - // Normal case: merge secret spec into main spec using DEEP merge - mainContent.spec = merge({}, mainContent.spec, content.spec) - // Keep the merged main file in allFiles for final storage - } else { - // Special case (users): no main file exists, secret IS the main - // Store at main path (without secrets. prefix) - allFiles.set(mainFilePath, content) + // PASS 2: Merge legacy secret files (secrets.*.yaml) into main files + // This provides backward compatibility for installations migrating from SOPS encryption + const hasSecretFiles = Array.from(allFiles.keys()).some((fp) => fp.includes('/secrets.')) + if (hasSecretFiles) { + for (const [filePath, content] of allFiles.entries()) { + if (filePath.includes('/secrets.')) { + // This is a secret file - find its main file + const mainFilePath = filePath.replace('/secrets.', '/') + const mainContent = allFiles.get(mainFilePath) + + if (mainContent) { + // Normal case: merge secret spec into main spec using DEEP merge + mainContent.spec = merge({}, mainContent.spec, content.spec) + } else { + // Special case (users): no main file exists, secret IS the main + // Store at main path (without secrets. prefix) + allFiles.set(mainFilePath, content) + } + // Remove secret file from map (don't store separately) + allFiles.delete(filePath) } - // Remove secret file from map (don't store separately) - allFiles.delete(filePath) } } @@ -334,6 +331,9 @@ export class FileStore { if (namespace) namespaces.add(namespace) } + // apl-users secrets are managed separately via user endpoints, not platform secrets + namespaces.delete('apl-users') + return Array.from(namespaces) } diff --git a/src/git.ts b/src/git.ts index 6b04f1d5c..ebbe31fd8 100644 --- a/src/git.ts +++ b/src/git.ts @@ -5,7 +5,7 @@ import { rmSync } from 'fs' import { copy, ensureDir, pathExists, readFile, writeFile } from 'fs-extra' import { unlink } from 'fs/promises' import { glob } from 'glob' -import { isEmpty, merge } from 'lodash' +import { merge } from 'lodash' import { basename, dirname, join } from 'path' import simpleGit, { CheckRepoActions, CleanOptions, CommitResult, ResetMode, SimpleGit } from 'simple-git' import { @@ -20,7 +20,7 @@ import { } from 'src/validators' import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' import { BASEURL } from './constants' -import { GitPullError, HttpError, ValidationError } from './error' +import { GitPullError } from './error' import { Core } from './otomi-models' import { getSanitizedErrorMessage, removeBlankAttributes, sanitizeGitPassword } from './utils' @@ -37,8 +37,6 @@ const env = cleanEnv({ }) const baseUrl = BASEURL -const prepareUrl = `${baseUrl}/prepare` -const initUrl = `${baseUrl}/init` const valuesUrl = `${baseUrl}/otomi/values` const getProtocol = (url): string => (url && url.includes('://') ? url.split('://')[0] : 'http') @@ -54,8 +52,6 @@ function getUrlAuth(url, user, password): string | undefined { return protocol === 'file' ? `${protocol}://${bareUrl}` : `${protocol}://${encodedUser}:${encodedPassword}@${bareUrl}` } -const secretFileRegex = new RegExp(`^(.*/)?secrets.*.yaml(.dec)?$`) - export class Git { branch: string commitSha: string @@ -66,7 +62,6 @@ export class Git { path: string remote: string remoteBranch: string - secretFilePostfix = '' url: string | undefined urlAuth: string | undefined user: string @@ -96,18 +91,6 @@ export class Git { return getProtocol(this.url) } - async requestInitValues(): Promise { - debug(`Tools: requesting "init" on values repo path ${this.path}`) - const res = await axios.get(initUrl, { params: { envDir: this.path } }) - return res - } - - async requestPrepareValues(files?: string[]): Promise { - debug(`Tools: requesting "prepare" on values repo path ${this.path}`) - const res = await axios.get(prepareUrl, { params: { envDir: this.path, files } }) - return res - } - async requestValues(params): Promise { debug(`Tools: requesting "otomi/values" ${this.path}`) const res = await axios.get(valuesUrl, { params: { envDir: this.path, ...params } }) @@ -134,15 +117,7 @@ export class Git { await this.git.addRemote(this.remote, this.url!) } - async initSops(): Promise { - if (this.secretFilePostfix === '.dec') return - this.secretFilePostfix = (await pathExists(join(this.path, '.sops.yaml'))) ? '.dec' : '' - } - getSafePath(file: string): string { - if (this.secretFilePostfix === '') return file - // otherwise we might have to give *.dec variant for secrets - if (file.match(secretFileRegex) && !file.endsWith(this.secretFilePostfix)) return `${file}${this.secretFilePostfix}` return file } @@ -151,18 +126,8 @@ export class Git { const exists = await this.fileExists(file) if (exists) { debug(`Removing file: ${absolutePath}`) - // Remove empty secret file due to https://github.com/mozilla/sops/issues/926 issue await unlink(absolutePath) } - if (file.match(secretFileRegex)) { - // also remove the encrypted file as they are operated on in pairs - const encFile = `${file}${this.secretFilePostfix}` - if (await this.fileExists(encFile)) { - const absolutePathEnc = join(this.path, encFile) - debug(`Removing enc file: ${absolutePathEnc}`) - await unlink(absolutePathEnc) - } - } } async removeDir(dir: string): Promise { @@ -185,10 +150,6 @@ export class Git { async writeFile(file: string, data: Record, unsetBlankAttributes = true): Promise { let cleanedData = data if (unsetBlankAttributes) cleanedData = removeBlankAttributes(data, { emptyArrays: true }) - if (isEmpty(cleanedData) && file.match(secretFileRegex)) { - // remove empty secrets file which sops can't handle - return this.removeFile(file) - } // we also bail when no changes found const hasDiff = await this.diffFile(file, data) if (!hasDiff) return @@ -304,8 +265,6 @@ export class Git { const summJson = JSON.stringify(summary) debug(`Pull summary: ${summJson}`) this.commitSha = await this.getCommitSha() - if (!skipRequest) await this.requestInitValues() - await this.initSops() } catch (e) { const eMessage = getSanitizedErrorMessage(e) debug('Could not pull from remote. Upstream commits? Marked db as corrupt.', eMessage) @@ -401,24 +360,7 @@ export class Git { return this.git.revparse('HEAD') } - async save(editor: string, encryptSecrets = true, files?: string[]): Promise { - // prepare values first - try { - if (encryptSecrets) { - await this.requestPrepareValues(files) - } else { - debug(`Data does not need to be encrypted`) - } - } catch (e) { - debug(`ERROR: ${JSON.stringify(e)}`) - if (e.response) { - const { status } = e.response as AxiosResponse - if (status === 422) throw new ValidationError() - throw HttpError.fromCode(status) - } - throw new HttpError(500, `${e}`) - } - // all good? commit + async save(editor: string): Promise { await this.commit(editor) try { // we are in a unique developer branch, so we can pull, push, and merge @@ -464,7 +406,6 @@ export async function getWorktreeRepo( const worktreeRepo = new Git(worktreePath, mainRepo.url, mainRepo.user, mainRepo.email, mainRepo.urlAuth, branch) await worktreeRepo.addConfig() - await worktreeRepo.initSops() return worktreeRepo } diff --git a/src/k8s_operations.ts b/src/k8s_operations.ts index 67f2bc8f4..c85d2d610 100644 --- a/src/k8s_operations.ts +++ b/src/k8s_operations.ts @@ -527,3 +527,87 @@ export async function getTeamSecretsFromK8s(namespace: string) { debug(`Failed to get team secrets from k8s for ${namespace}.`) } } + +export interface UserSecretData { + id: string + email: string + firstName: string + lastName: string + initialPassword: string + isPlatformAdmin: boolean + isTeamAdmin: boolean + teams: string[] +} + +function decodeUserSecret(name: string, data: Record): UserSecretData { + const decoded: Record = {} + Object.entries(data || {}).forEach(([key, value]) => { + decoded[key] = Buffer.from(value, 'base64').toString('utf-8') + }) + return { + id: name, + email: decoded.email || '', + firstName: decoded.firstName || '', + lastName: decoded.lastName || '', + initialPassword: decoded.initialPassword || '', + isPlatformAdmin: decoded.isPlatformAdmin === 'true', + isTeamAdmin: decoded.isTeamAdmin === 'true', + teams: decoded.teams ? JSON.parse(decoded.teams) : [], + } +} + +/** + * List all user secrets from the apl-users namespace and return decoded user data. + */ +export async function listUserSecretsFromK8s(namespace = 'apl-users'): Promise { + const kc = new KubeConfig() + kc.loadFromDefault() + const k8sApi = kc.makeApiClient(CoreV1Api) + try { + const res: any = await k8sApi.listNamespacedSecret({ namespace }) + const users: UserSecretData[] = [] + for (const item of res.items || []) { + // Skip service account tokens and other non-user secrets + if (item.type !== 'kubernetes.io/opaque') continue + if (!item.data?.email) continue + users.push(decodeUserSecret(item.metadata.name, item.data)) + } + return users + } catch (error) { + debug(`Failed to list user secrets from k8s for ${namespace}.`) + return [] + } +} + +/** + * Read a single user's K8s secret from the apl-users namespace. + */ +export async function getUserSecretFromK8s(uuid: string, namespace = 'apl-users'): Promise { + const kc = new KubeConfig() + kc.loadFromDefault() + const k8sApi = kc.makeApiClient(CoreV1Api) + try { + const res = await k8sApi.readNamespacedSecret({ name: uuid, namespace }) + if (!res.data) return undefined + return decodeUserSecret(uuid, res.data) + } catch (error) { + debug(`Failed to get user secret ${uuid} from k8s for ${namespace}.`) + return undefined + } +} + +let _k8sReachable: boolean | null = null + +export async function isK8sReachable(): Promise { + if (_k8sReachable !== null) return _k8sReachable + try { + const kc = new KubeConfig() + kc.loadFromDefault() + const versionApi = kc.makeApiClient(VersionApi) + await versionApi.getCode() + _k8sReachable = true + } catch { + _k8sReachable = false + } + return _k8sReachable +} diff --git a/src/middleware/jwt.test.ts b/src/middleware/jwt.test.ts index 23be850e8..d13c21fc7 100644 --- a/src/middleware/jwt.test.ts +++ b/src/middleware/jwt.test.ts @@ -6,6 +6,16 @@ import { Git } from '../git' import * as getValuesSchemaModule from '../utils' import { getUser } from './jwt' +jest.mock('../utils/sealedSecretUtils', () => { + const originalModule = jest.requireActual('../utils/sealedSecretUtils') + return { + __esModule: true, + ...originalModule, + createPlatformSealedSecretManifest: jest.fn().mockResolvedValue('mock-sealed-secret-yaml'), + createUserSealedSecret: jest.fn().mockResolvedValue('mock-user-sealed-secret-yaml'), + } +}) + const email = 'test@user.net' const platformAdminGroups = ['platform-admin', 'all-teams-admin'] const teamAdminGroups = ['team-admin'] diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index bd886589a..c906e9033 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -38,6 +38,29 @@ jest.mock('src/utils/userUtils', () => { } }) +const mockListUserSecretsFromK8s = jest.fn().mockResolvedValue([]) +const mockGetUserSecretFromK8s = jest.fn().mockResolvedValue(undefined) +jest.mock('./k8s_operations', () => { + const originalModule = jest.requireActual('./k8s_operations') + return { + __esModule: true, + ...originalModule, + listUserSecretsFromK8s: (...args: any[]) => mockListUserSecretsFromK8s(...args), + getUserSecretFromK8s: (...args: any[]) => mockGetUserSecretFromK8s(...args), + getSecretValues: jest.fn().mockResolvedValue({ adminPassword: 'test-admin-password' }), + } +}) + +jest.mock('./utils/sealedSecretUtils', () => { + const originalModule = jest.requireActual('./utils/sealedSecretUtils') + return { + __esModule: true, + ...originalModule, + createPlatformSealedSecretManifest: jest.fn().mockResolvedValue('mock-sealed-secret-yaml'), + createUserSealedSecret: jest.fn().mockResolvedValue('mock-user-sealed-secret-yaml'), + } +}) + beforeAll(async () => { jest.spyOn(console, 'log').mockImplementation(() => {}) jest.spyOn(console, 'debug').mockImplementation(() => {}) @@ -47,11 +70,38 @@ beforeAll(async () => { await loadSpec() }) +// Track users created in tests for K8s mock +const testK8sUsers: any[] = [] + +beforeEach(() => { + testK8sUsers.length = 0 + mockListUserSecretsFromK8s.mockResolvedValue([]) + mockGetUserSecretFromK8s.mockResolvedValue(undefined) +}) + // Helper functions for FileStore-based tests function createTestUser(otomiStack: OtomiStack, user: User): void { const { buildPlatformObject } = require('./otomi-models') const aplUser = buildPlatformObject('AplUser', user.id!, user as any) otomiStack.fileStore.setPlatformResource(aplUser) + + // Also register in K8s mock for getAllUsers/getUser + const k8sUser = { + id: user.id, + email: user.email, + firstName: user.firstName || '', + lastName: user.lastName || '', + initialPassword: user.initialPassword || '', + isPlatformAdmin: user.isPlatformAdmin || false, + isTeamAdmin: user.isTeamAdmin || false, + teams: user.teams || [], + } + testK8sUsers.push(k8sUser) + mockListUserSecretsFromK8s.mockResolvedValue([...testK8sUsers]) + mockGetUserSecretFromK8s.mockImplementation((id: string) => { + const found = testK8sUsers.find((u) => u.id === id) + return Promise.resolve(found || undefined) + }) } function createTestTeam(otomiStack: OtomiStack, teamId: string, spec: any = {}): void { @@ -156,27 +206,27 @@ describe('Data validation', () => { test('should create a password when password is not specified', async () => { await otomiStack.createTeam({ name: 'test' }) - // Verify FileStore was updated with a generated password + // Password should NOT be in the team settings (it's in a SealedSecret now) const teamSettings = otomiStack.fileStore.getTeamResource('AplTeamSettingSet', 'test', 'settings') expect(teamSettings).toBeDefined() - expect(teamSettings?.spec.password).toBeTruthy() - expect(teamSettings?.spec.password.length).toBeGreaterThan(0) + expect(teamSettings?.spec.password).toBeUndefined() - // Verify Git operations were called + // Verify Git operations were called (writeFile for team settings + writeTextFile for SealedSecret) expect(mockGit.writeFile).toHaveBeenCalled() + expect(mockGit.writeTextFile).toHaveBeenCalled() }) test('should not create a password when password is specified', async () => { const myPassword = 'someAwesomePassword' await otomiStack.createTeam({ name: 'test', password: myPassword }) - // Verify FileStore was updated with the specified password + // Password should NOT be in the team settings (it's encrypted in a SealedSecret) const teamSettings = otomiStack.fileStore.getTeamResource('AplTeamSettingSet', 'test', 'settings') expect(teamSettings).toBeDefined() - expect(teamSettings?.spec.password).toBe(myPassword) + expect(teamSettings?.spec.password).toBeUndefined() - // Verify Git operations were called - expect(mockGit.writeFile).toHaveBeenCalled() + // Verify Git operations were called (SealedSecret was written) + expect(mockGit.writeTextFile).toHaveBeenCalled() }) test('should throw ValidationError when team name is under 3 characters', async () => { @@ -280,17 +330,17 @@ describe('Workload values', () => { jest.spyOn(otomiStack, 'doDeployment').mockResolvedValue() }) - test('returns filtered apps if App array is submitted isPreinstalled flag is true', () => { + test('returns filtered apps if App array is submitted isPreinstalled flag is true', async () => { const apps: App[] = [{ id: 'external-dns' }, { id: 'cnpg' }, { id: 'loki' }] - jest.spyOn(otomiStack, 'getSettingsInfo').mockReturnValue({ otomi: { isPreInstalled: true } }) - const filteredApps = otomiStack.filterExcludedApp(apps) + jest.spyOn(otomiStack, 'getSettingsInfo').mockResolvedValue({ otomi: { isPreInstalled: true } }) + const filteredApps = await otomiStack.filterExcludedApp(apps) expect(filteredApps).toEqual([{ id: 'cnpg' }, { id: 'loki' }]) }) - test('returns app with managed = true if single App is in excludedList and isPreinstalled flag is true', () => { + test('returns app with managed = true if single App is in excludedList and isPreinstalled flag is true', async () => { const app: App = { id: 'external-dns' } - jest.spyOn(otomiStack, 'getSettingsInfo').mockReturnValue({ otomi: { isPreInstalled: true } }) - const filteredApp = otomiStack.filterExcludedApp(app) + jest.spyOn(otomiStack, 'getSettingsInfo').mockResolvedValue({ otomi: { isPreInstalled: true } }) + const filteredApp = await otomiStack.filterExcludedApp(app) expect(filteredApp).toEqual({ id: 'external-dns', managed: true }) }) }) @@ -393,7 +443,7 @@ describe('Users tests', () => { const { getSessionStack } = require('src/middleware') jest.mocked(getSessionStack).mockResolvedValue(otomiStack) - jest.spyOn(otomiStack, 'getSettings').mockReturnValue({ + jest.spyOn(otomiStack, 'getSettings').mockResolvedValue({ cluster: { name: 'default-cluster', domainSuffix, provider: 'linode' }, }) jest.spyOn(otomiStack, 'doDeleteDeployment').mockResolvedValue() @@ -436,13 +486,13 @@ describe('Users tests', () => { createTestUser(otomiStack, teamMember1) }) - it('should return full user for platform admin', () => { - const result = otomiStack.getUser(teamMember1.id!, platformAdminSession) + it('should return full user for platform admin', async () => { + const result = await otomiStack.getUser(teamMember1.id!, platformAdminSession) expect(result).toMatchObject(teamMember1) }) - it('should return limited user info for team admin', () => { - const result = otomiStack.getUser(teamMember1.id!, teamAdminSession) + it('should return limited user info for team admin', async () => { + const result = await otomiStack.getUser(teamMember1.id!, teamAdminSession) expect(result).toEqual({ id: teamMember1.id, email: teamMember1.email, @@ -452,22 +502,22 @@ describe('Users tests', () => { }) }) - it('should throw 403 for regular user', () => { + it('should throw 403 for regular user', async () => { try { - otomiStack.getUser(teamMember1.id!, { ...sessionUser, isPlatformAdmin: false, isTeamAdmin: false }) + await otomiStack.getUser(teamMember1.id!, { ...sessionUser, isPlatformAdmin: false, isTeamAdmin: false }) fail('Expected error was not thrown') } catch (err: any) { expect(err).toHaveProperty('code', 403) } }) - it('should return all users for platform admin in getAllUsers', () => { - const users = otomiStack.getAllUsers(platformAdminSession) + it('should return all users for platform admin in getAllUsers', async () => { + const users = await otomiStack.getAllUsers(platformAdminSession) expect(users.some((u) => u.id === teamMember1.id)).toBe(true) }) - it('should return limited info for team admin in getAllUsers', () => { - const users = otomiStack.getAllUsers(teamAdminSession) + it('should return limited info for team admin in getAllUsers', async () => { + const users = await otomiStack.getAllUsers(teamAdminSession) expect(users[0]).toHaveProperty('id') expect(users[0]).toHaveProperty('email') expect(users[0]).toHaveProperty('isPlatformAdmin') @@ -478,9 +528,9 @@ describe('Users tests', () => { expect(users[0]).not.toHaveProperty('lastName') }) - it('should throw 403 for regular user in getAllUsers', () => { + it('should throw 403 for regular user in getAllUsers', async () => { try { - otomiStack.getAllUsers({ ...sessionUser, isPlatformAdmin: false, isTeamAdmin: false }) + await otomiStack.getAllUsers({ ...sessionUser, isPlatformAdmin: false, isTeamAdmin: false }) fail('Expected error was not thrown') } catch (err: any) { expect(err).toHaveProperty('code', 403) @@ -578,9 +628,8 @@ describe('Users tests', () => { expect(result.firstName).toBe('edited') - // Verify FileStore was updated - const storedUser = otomiStack.fileStore.get(`env/users/${user.id}.yaml`) - expect(storedUser?.spec.firstName).toBe('edited') + // Verify SealedSecret was written via Git + expect(mockGit.writeTextFile).toHaveBeenCalled() }) it('should not allow non-platform admin to edit a user', async () => { @@ -732,11 +781,11 @@ describe('getVersions', () => { jest.restoreAllMocks() }) - test('should return versions with otomi version from settings', () => { + test('should return versions with otomi version from settings', async () => { const mockSettings = { otomi: { version: '1.2.3' } } - jest.spyOn(otomiStack, 'getSettings').mockReturnValue(mockSettings) + jest.spyOn(otomiStack, 'getSettings').mockResolvedValue(mockSettings) - const result = (otomiStack as any).getVersions('abc123') + const result = await (otomiStack as any).getVersions('abc123') expect(result).toHaveProperty('core', '1.2.3') expect(result).toHaveProperty('api') @@ -745,11 +794,11 @@ describe('getVersions', () => { expect(otomiStack.getSettings).toHaveBeenCalledWith(['otomi']) }) - test('should fallback to env.VERSIONS.core when otomi.version is not available', () => { + test('should fallback to env.VERSIONS.core when otomi.version is not available', async () => { const mockSettings = { otomi: undefined } - jest.spyOn(otomiStack, 'getSettings').mockReturnValue(mockSettings) + jest.spyOn(otomiStack, 'getSettings').mockResolvedValue(mockSettings) - const result = (otomiStack as any).getVersions('def456') + const result = await (otomiStack as any).getVersions('def456') expect(result).toHaveProperty('core') expect(result).toHaveProperty('api') @@ -757,14 +806,14 @@ describe('getVersions', () => { expect(result).toHaveProperty('values', 'def456') }) - test('should fallback to process.env.npm_package_version when env.VERSIONS.api is not available', () => { + test('should fallback to process.env.npm_package_version when env.VERSIONS.api is not available', async () => { const originalNpmVersion = process.env.npm_package_version process.env.npm_package_version = '5.0.0' const mockSettings = { otomi: { version: '1.2.3' } } - jest.spyOn(otomiStack, 'getSettings').mockReturnValue(mockSettings) + jest.spyOn(otomiStack, 'getSettings').mockResolvedValue(mockSettings) - const result = (otomiStack as any).getVersions('ghi789') + const result = await (otomiStack as any).getVersions('ghi789') expect(result).toHaveProperty('core', '1.2.3') expect(result).toHaveProperty('api') @@ -774,11 +823,11 @@ describe('getVersions', () => { process.env.npm_package_version = originalNpmVersion }) - test('should handle undefined otomi settings gracefully', () => { + test('should handle undefined otomi settings gracefully', async () => { const mockSettings = {} - jest.spyOn(otomiStack, 'getSettings').mockReturnValue(mockSettings) + jest.spyOn(otomiStack, 'getSettings').mockResolvedValue(mockSettings) - const result = (otomiStack as any).getVersions('xyz123') + const result = await (otomiStack as any).getVersions('xyz123') expect(result).toHaveProperty('core') expect(result).toHaveProperty('api') @@ -786,22 +835,22 @@ describe('getVersions', () => { expect(result).toHaveProperty('values', 'xyz123') }) - test('should pass through currentSha as values field', () => { + test('should pass through currentSha as values field', async () => { const mockSettings = { otomi: { version: '1.0.0' } } - jest.spyOn(otomiStack, 'getSettings').mockReturnValue(mockSettings) + jest.spyOn(otomiStack, 'getSettings').mockResolvedValue(mockSettings) const testSha = 'unique-commit-sha-123' - const result = (otomiStack as any).getVersions(testSha) + const result = await (otomiStack as any).getVersions(testSha) expect(result.values).toBe(testSha) expect(typeof result.values).toBe('string') }) - test('should return all required version fields', () => { + test('should return all required version fields', async () => { const mockSettings = { otomi: { version: '1.0.0' } } - jest.spyOn(otomiStack, 'getSettings').mockReturnValue(mockSettings) + jest.spyOn(otomiStack, 'getSettings').mockResolvedValue(mockSettings) - const result = (otomiStack as any).getVersions('test-sha') + const result = await (otomiStack as any).getVersions('test-sha') expect(Object.keys(result).sort()).toEqual(['api', 'console', 'core', 'values']) expect(typeof result.core).toBe('string') diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 60a415884..81ea0cbf2 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1,4 +1,4 @@ -import { CoreV1Api, KubeConfig, User as k8sUser, V1ObjectReference } from '@kubernetes/client-node' +import { CoreV1Api, User as k8sUser, KubeConfig, V1ObjectReference } from '@kubernetes/client-node' import Debug from 'debug' import { getRegions, ObjectStorageKeyRegions } from '@linode/api-v4' @@ -6,8 +6,8 @@ import { existsSync, rmSync } from 'fs' import { pathExists, unlink } from 'fs-extra' import { readdir, readFile, writeFile } from 'fs/promises' import { generate as generatePassword } from 'generate-password' -import { cloneDeep, filter, get, isEmpty, map, merge, omit, pick, set, unset } from 'lodash' -import { getAppList, getAppSchema, getSecretPaths } from 'src/app' +import { cloneDeep, filter, isEmpty, map, merge, omit, pick, set, unset } from 'lodash' +import { getAppList, getAppSchema } from 'src/app' import { AlreadyExists, BadRequestError, @@ -113,7 +113,7 @@ import { v4 as uuidv4 } from 'uuid' import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' import { getAIModels } from './ai/aiModelHandler' import { DatabaseCR } from './ai/DatabaseCR' -import { getResourceFilePath, getSecretFilePath } from './fileStore/file-map' +import { getResourceFilePath } from './fileStore/file-map' import { apply, checkPodExists, @@ -121,6 +121,7 @@ import { getKubernetesVersion, getSecretValues, getTeamSecretsFromK8s, + isK8sReachable, k8sdelete, watchPodUntilRunning, } from './k8s_operations' @@ -133,8 +134,24 @@ import { testPublicRepoConnect, } from './utils/codeRepoUtils' import { getAplObjectFromV1, getV1MergeObject, getV1ObjectFromApl } from './utils/manifests' -import { ensureSealedSecretMetadata, getSealedSecretsPEM, sealedSecretManifest } from './utils/sealedSecretUtils' -import { getKeycloakUsers, isValidUsername } from './utils/userUtils' +import { + createPlatformSealedSecretManifest, + createUserSealedSecret, + ensureEncryptedData, + ensureSealedSecretMetadata, + extractSecretPaths, + extractSettingsSecrets, + getSealedSecretsPEM, + removeSettingsSecrets, + sealedSecretManifest, +} from './utils/sealedSecretUtils' +import { + getKeycloakUsers, + getUserSecretData, + isValidUsername, + listUserSecretData, + userSecretDataToUser, +} from './utils/userUtils' import { defineClusterId, ObjectStorageClient } from './utils/wizardUtils' import { fetchChartYaml, @@ -211,10 +228,10 @@ export default class OtomiStack { this.sessionId = sessionId ?? 'main' } - getAppList() { + async getAppList() { let apps = getAppList() apps = apps.filter((item) => item !== 'ingress-nginx') - const { ingress } = this.getSettings() + const { ingress } = await this.getSettings() const allClasses = ['platform'].concat(ingress?.classes?.map((obj) => obj.className as string) || []) const ingressApps = allClasses.map((name) => `ingress-nginx-${name}`) return apps.concat(ingressApps) @@ -308,8 +325,8 @@ export default class OtomiStack { debug(`Worktree created for ${this.editor} in ${this.sessionId}`) } - getSettingsInfo(): SettingsInfo { - const settings = this.getSettings(['cluster', 'dns', 'otomi', 'smtp', 'ingress']) + async getSettingsInfo(): Promise { + const settings = await this.getSettings(['cluster', 'dns', 'otomi', 'smtp', 'ingress']) const otomiInfo = pick(settings.otomi, [ 'hasExternalDNS', 'hasExternalIDP', @@ -331,11 +348,11 @@ export default class OtomiStack { } async createObjWizard(data: ObjWizard): Promise { - const { obj } = this.getSettings(['obj']) + const { obj } = await this.getSettings(['obj']) const settingsdata = { obj: { ...obj, showWizard: data.showWizard } } const createdBuckets = [] as Array if (data?.apiToken && data?.regionId) { - const { cluster } = this.getSettings(['cluster']) + const { cluster } = await this.getSettings(['cluster']) let lkeClusterId: undefined | string = defineClusterId(cluster?.name) if (lkeClusterId === undefined) { return { status: 'error', errorMessage: 'Cluster name is not found.' } @@ -405,7 +422,7 @@ export default class OtomiStack { } as ObjWizard } - getSettings(keys?: string[]): Settings { + async getSettings(keys?: string[]): Promise { const settings: Settings = {} const settingsFileMaps = getSettingsFileMaps(this.getRepoPath()) @@ -425,21 +442,45 @@ export default class OtomiStack { if (keys.includes('otomi')) { this.transformOtomiNodeSelector(settings) } + } else { + // No keys specified: fetch all settings + for (const [name, fileMap] of settingsFileMaps.entries()) { + const files = this.fileStore.getPlatformResourcesByKind(fileMap.kind) + for (const [, content] of files) { + settings[name] = content?.spec || content + } + } - return settings + // Apply otomi nodeSelector transformation + this.transformOtomiNodeSelector(settings) } - // No keys specified: fetch all settings - for (const [name, fileMap] of settingsFileMaps.entries()) { - const files = this.fileStore.getPlatformResourcesByKind(fileMap.kind) - for (const [, content] of files) { - settings[name] = content?.spec || content + // Merge sealed secret encrypted data back into settings at their original dot-paths + const valuesSchema = await getValuesSchema() + const settingKeys = keys && keys.length > 0 ? keys : Array.from(settingsFileMaps.keys()) + + for (const settingId of settingKeys) { + if (!settings[settingId]) continue + + const subSchema = valuesSchema.properties?.[settingId] + if (!subSchema) continue + + const secretPaths = extractSecretPaths(subSchema) + if (secretPaths.length === 0) continue + + const sealedSecretName = `${settingId}-secrets` + const manifest = this.fileStore.getNamespaceResource('AplNamespaceSealedSecret', sealedSecretName, 'apl-secrets') + if (!manifest) continue + + const encryptedData = (manifest as SealedSecretManifestResponse).spec?.encryptedData || {} + for (const dotPath of secretPaths) { + const underscoreKey = dotPath.replace(/\./g, '_') + if (underscoreKey in encryptedData) { + set(settings[settingId] as Record, dotPath, encryptedData[underscoreKey]) + } } } - // Apply otomi nodeSelector transformation - this.transformOtomiNodeSelector(settings) - return settings } @@ -508,21 +549,12 @@ export default class OtomiStack { } async editSettings(data: Settings, settingId: string): Promise { - const settings = this.getSettings() + const settings = await this.getSettings() await this.editIngressApps(settings, data, settingId) const updatedSettingsData: any = { ...data } - // Preserve the otomi.adminPassword and otomi.git.password if not present when editing otomi settings if (settingId === 'otomi') { - updatedSettingsData.otomi = { - ...updatedSettingsData.otomi, - adminPassword: settings.otomi?.adminPassword, - git: { - ...updatedSettingsData.otomi?.git, - ...(!updatedSettingsData.otomi?.git?.password && { password: settings.otomi?.git?.password }), - }, - } // convert otomi.nodeSelector to object - if (Array.isArray(updatedSettingsData.otomi.nodeSelector)) { + if (Array.isArray(updatedSettingsData.otomi?.nodeSelector)) { const nodeSelectorArray = updatedSettingsData.otomi.nodeSelector const nodeSelectorObject = nodeSelectorArray.reduce((acc, { name, value }) => { return { ...acc, [name]: value } @@ -531,7 +563,67 @@ export default class OtomiStack { } } - settings[settingId] = removeBlankAttributes(updatedSettingsData[settingId] as Record) + // Extract secrets from settings data and store as SealedSecret + const valuesSchema = await getValuesSchema() + const subSchema = valuesSchema.properties?.[settingId] + let sealedSecretRecord: AplRecord | undefined + if (subSchema) { + const secretPaths = extractSecretPaths(subSchema) + const newSecrets = extractSettingsSecrets(secretPaths, updatedSettingsData[settingId]) + const sealedSecretName = `${settingId}-secrets` + const sealedSecretPath = `env/manifests/namespaces/apl-secrets/sealedsecrets/${sealedSecretName}.yaml` + + // Merge new secrets with existing sealed secret so unchanged secrets are preserved. + // Existing values are already encrypted; only new plaintext values get encrypted by + // createPlatformSealedSecretManifest, so we encrypt new values first, then merge. + if (Object.keys(newSecrets).length > 0) { + const existingManifest = await this.git.readFile(sealedSecretPath) + const existingEncryptedData: Record = + (existingManifest?.spec?.encryptedData as Record) || {} + + // Filter out unchanged secrets: if the incoming value matches the existing + // encrypted value, the user didn't change it — skip to avoid double encryption + const changedSecrets: Record = {} + for (const [key, value] of Object.entries(newSecrets)) { + if (existingEncryptedData[key] !== value) { + changedSecrets[key] = value + } + } + + let mergedEncryptedData: Record + if (Object.keys(changedSecrets).length > 0) { + // Encrypt only the actually changed values + const freshYaml = await createPlatformSealedSecretManifest(sealedSecretName, 'apl-secrets', changedSecrets) + const freshManifest = parseYaml(freshYaml) as Record + const freshEncryptedData: Record = freshManifest.spec.encryptedData as Record + mergedEncryptedData = { ...existingEncryptedData, ...freshEncryptedData } + } else { + // Nothing actually changed — keep existing data as-is + mergedEncryptedData = existingEncryptedData + } + + // Save via saveNamespaceSealedSecret to update both disk and fileStore + // using the same code path as the /namespaces endpoint + sealedSecretRecord = await this.saveNamespaceSealedSecret('apl-secrets', { + kind: 'SealedSecret', + metadata: { name: sealedSecretName }, + spec: { + encryptedData: mergedEncryptedData, + template: { + type: 'kubernetes.io/opaque', + immutable: false, + metadata: { name: sealedSecretName, namespace: 'apl-secrets' }, + }, + }, + }) + } + // Remove secrets from a clone for disk storage — keep originals for the response + const diskData = cloneDeep(updatedSettingsData[settingId]) + removeSettingsSecrets(secretPaths, diskData) + settings[settingId] = removeBlankAttributes(diskData as Record) + } else { + settings[settingId] = removeBlankAttributes(updatedSettingsData[settingId] as Record) + } const settingKindMap = getSettingsFileMaps(this.getRepoPath()) const kind = settingKindMap.get(settingId) @@ -545,17 +637,20 @@ export default class OtomiStack { this.fileStore.set(filePath, aplObject) await this.saveSettings() - await this.doDeployment({ filePath, content: aplObject }, true, [ - `${this.getRepoPath()}/env/settings/secrets.${settingId}.yaml`, - ]) + const aplRecords: AplRecord[] = [{ filePath, content: aplObject }] + if (sealedSecretRecord) aplRecords.push(sealedSecretRecord) + await this.doDeployments(aplRecords) + + // Return settings with secret values from the incoming data + settings[settingId] = removeBlankAttributes(updatedSettingsData[settingId] as Record) return settings } - filterExcludedApp(apps: App | App[]) { + async filterExcludedApp(apps: App | App[]) { const preInstalledExcludedApps = env.PREINSTALLED_EXCLUDED_APPS.apps const hiddenApps = env.HIDDEN_APPS.apps const excludedApps = preInstalledExcludedApps.concat(hiddenApps) - const settingsInfo = this.getSettingsInfo() + const settingsInfo = await this.getSettingsInfo() if (!Array.isArray(apps)) { if (settingsInfo.otomi && settingsInfo.otomi.isPreInstalled && excludedApps.includes(apps.id)) { // eslint-disable-next-line no-param-reassign @@ -572,9 +667,9 @@ export default class OtomiStack { return apps } - getTeamApp(teamId: string, id: string): App | ExcludedApp { + async getTeamApp(teamId: string, id: string): Promise { const app = this.getApp(id) - this.filterExcludedApp(app) + await this.filterExcludedApp(app) if (teamId === 'admin') return app return { id: app.id, enabled: app.enabled } @@ -591,14 +686,14 @@ export default class OtomiStack { return { values: content.spec, id: content.metadata.name } as App } - getApps(): Array { - const appList = this.getAppList() + async getApps(): Promise> { + const appList = await this.getAppList() const allApps = appList.map((id) => { return this.getApp(id) }) - const providerSpecificApps = this.filterExcludedApp(allApps) as App[] + const providerSpecificApps = (await this.filterExcludedApp(allApps)) as App[] return providerSpecificApps.map((app) => { return { @@ -608,8 +703,8 @@ export default class OtomiStack { }) } - getTeamApps(teamId: string): Array { - const allApps = this.getApps() + async getTeamApps(teamId: string): Promise> { + const allApps = await this.getApps() if (teamId === 'admin') return allApps @@ -645,7 +740,7 @@ export default class OtomiStack { this.fileStore.set(filePath, aplApp) await this.saveAdminApp(app) - await this.doDeployment({ filePath, content: aplApp }, true, [`${this.getRepoPath()}/env/apps/secrets.${id}.yaml`]) + await this.doDeployment({ filePath, content: aplApp }) return this.getApp(id) } @@ -678,11 +773,7 @@ export default class OtomiStack { if (aplRecords.length === 0) { throw new Error(`Failed toggling apps ${ids.toString()}`) } - await this.doDeployments( - aplRecords, - true, - ids.map((id) => `${this.getRepoPath()}/env/apps/secrets.${id}.yaml`), - ) + await this.doDeployments(aplRecords) } getTeams(): Array { @@ -773,10 +864,10 @@ export default class OtomiStack { if (teamName.length < 3) throw new ValidationError('Team name must be at least 3 characters long') if (teamName.length > 9) throw new ValidationError('Team name must not exceed 9 characters') - if (isEmpty(data.spec.password)) { + let password = data.spec.password as string + if (isEmpty(password)) { debug(`creating password for team '${teamName}'`) - // eslint-disable-next-line no-param-reassign - data.spec.password = generatePassword({ + password = generatePassword({ length: 16, numbers: true, symbols: false, @@ -786,9 +877,19 @@ export default class OtomiStack { }) } + // Encrypt password into a SealedSecret manifest + const sealedSecretName = `team-${teamName}-settings-secrets` + const sealedSecretYaml = await createPlatformSealedSecretManifest(sealedSecretName, 'apl-secrets', { password }) + const sealedSecretPath = `env/manifests/namespaces/apl-secrets/sealedsecrets/${sealedSecretName}.yaml` + await this.git.writeTextFile(sealedSecretPath, sealedSecretYaml) + + // Remove password from team spec before saving settings.yaml + // eslint-disable-next-line no-param-reassign + delete data.spec.password + const teamObject = toTeamObject(teamName, data) const team = await this.saveTeam(teamObject) - await this.doDeployment(team, true, [`${this.getRepoPath()}/env/teams/${teamName}/secrets.settings.yaml`]) + await this.doDeployment(team) return team.content as AplTeamSettingsResponse } @@ -809,7 +910,7 @@ export default class OtomiStack { const teamObject = buildTeamObject(currentTeam, updatedSpec) const team = await this.saveTeam(teamObject) - await this.doDeployment(team, true, [`${this.getRepoPath()}/env/teams/${name}/secrets.settings.yaml`]) + await this.doDeployment(team) return team.content as AplTeamSettingsResponse } @@ -871,9 +972,18 @@ export default class OtomiStack { return { filePath, content: data } } - async saveTeamSealedSecret(teamId: string, data: SealedSecretManifestRequest): Promise { + async saveTeamSealedSecret(teamId: string, inData: SealedSecretManifestRequest): Promise { + const data = { ...inData } debug(`Saving sealed secrets of team: ${teamId}`) const { metadata } = data + + // Server-side encryption fallback: ensureEncryptedData checks each value using isEncryptedValue(), + // which detects plain text by verifying that kubeseal ciphertext is always a long (200+ chars) base64 string. + // Any value that is shorter or not valid base64 is treated as plain text and encrypted server-side. + if (data.spec.encryptedData && Object.keys(data.spec.encryptedData).length > 0) { + data.spec.encryptedData = await ensureEncryptedData(data.spec.encryptedData, teamId) + } + const sealedSecretChartValues = sealedSecretManifest(teamId, data) const aplRecord = this.fileStore.set( getTeamSealedSecretsValuesFilePath(teamId, metadata.name), @@ -957,7 +1067,7 @@ export default class OtomiStack { const teamObject = toTeamObject(teamId, data) const aplRecord = await this.saveTeamConfigItem(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplNetpolResponse } @@ -994,7 +1104,7 @@ export default class OtomiStack { const teamObject = buildTeamObject(existing, updatedSpec) const aplRecord = await this.saveTeamConfigItem(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplNetpolResponse } @@ -1003,20 +1113,18 @@ export default class OtomiStack { await this.doDeleteDeployment([filePath]) } - getAllUsers(sessionUser: SessionUser): Array { - const files = this.fileStore.getPlatformResourcesByKind('AplUser') - const aplObjects = Array.from(files.values()) as AplObject[] - const users = aplObjects.map((aplObject) => { - return { ...aplObject.spec, id: aplObject.metadata.name } as User - }) + async getAllUsers(sessionUser: SessionUser): Promise> { + const usersData = await listUserSecretData(this.getAplNamespaceSealedSecrets.bind(this)) + const users: User[] = usersData.map((u) => userSecretDataToUser(u)) + if (sessionUser.isPlatformAdmin) { return users } else if (sessionUser.isTeamAdmin) { const usersWithBasicInfo = users.map((user) => { const { id, email, isPlatformAdmin, isTeamAdmin, teams } = user - return { id, email, isPlatformAdmin, isTeamAdmin, teams } + return { id, email, isPlatformAdmin, isTeamAdmin, teams } as User }) - return usersWithBasicInfo as Array + return usersWithBasicInfo } throw new ForbiddenError() } @@ -1037,40 +1145,47 @@ export default class OtomiStack { const userId = uuidv4() const user: User = { ...data, id: userId, initialPassword } - // Get existing users' emails - const files = this.fileStore.getPlatformResourcesByKind('AplUser') - let existingUsersEmail = Array.from(files.values()).map((aplObject: AplObject) => aplObject.spec.email) + // Check for existing users + const existingUsers = await listUserSecretData(this.getAplNamespaceSealedSecrets.bind(this)) + const existingUsersEmail = existingUsers.map((u) => u.email) if (!env.isDev) { - const { otomi, cluster } = this.getSettings(['otomi', 'cluster']) + // In production, also check Keycloak for existing users + const { cluster } = await this.getSettings(['cluster']) const keycloak = this.getApp('keycloak') const keycloakBaseUrl = `https://keycloak.${cluster?.domainSuffix}` const realm = 'otomi' const username = keycloak?.values?.adminUsername as string - const password = otomi?.adminPassword as string - existingUsersEmail = await getKeycloakUsers(keycloakBaseUrl, realm, username, password) + const platformSecrets = await getSecretValues('otomi-platform-secrets', 'apl-secrets') + const adminPassword = platformSecrets?.adminPassword + if (!adminPassword) { + throw new HttpError(500, 'Admin password not found in platform secrets') + } + const keycloakEmails = await getKeycloakUsers(keycloakBaseUrl, realm, username, adminPassword) + existingUsersEmail.push(...keycloakEmails.filter((e) => !existingUsersEmail.includes(e))) } + if (existingUsersEmail.some((existingUser) => existingUser === user.email)) { throw new AlreadyExists('User email already exists') } const aplRecord = await this.saveUser(user) - await this.doDeployment(aplRecord, true, [`${this.getRepoPath()}/env/users/secrets.${userId}.yaml`]) + await this.doDeployment(aplRecord) return user } - getUser(id: string, sessionUser: SessionUser): User { - const filePath = getResourceFilePath('AplUser', id) - const user = this.fileStore.get(filePath) - if (!user) { + async getUser(id: string, sessionUser: SessionUser): Promise { + const userData = await getUserSecretData(id, this.fileStore) + if (!userData) { throw new NotExistError(`User ${id} not found`) } + const user = userSecretDataToUser(userData) if (sessionUser.isPlatformAdmin) { - return { ...user.spec, id } as User + return user } if (sessionUser.isTeamAdmin) { - const { email, isPlatformAdmin, isTeamAdmin, teams } = user.spec + const { email, isPlatformAdmin, isTeamAdmin, teams } = user return { id, email, isPlatformAdmin, isTeamAdmin, teams } as User } throw new ForbiddenError() @@ -1081,32 +1196,44 @@ export default class OtomiStack { throw new ForbiddenError('Only platform admins can modify user details.') } - const filePath = getResourceFilePath('AplUser', id) - const existing = this.fileStore.get(filePath) - if (!existing) { + const existingData = await getUserSecretData(id, this.fileStore) + if (!existingData) { throw new NotExistError(`User ${id} not found`) } + const existingUser = userSecretDataToUser(existingData) - const user: User = { ...existing, ...data, id } + // Merge updates, preserving initialPassword from existing secret + const user: User = { + ...existingUser, + ...data, + id, + initialPassword: existingUser.initialPassword, + } const aplRecord = await this.saveUser(user) - await this.doDeployment(aplRecord, true, [`${this.getRepoPath()}/env/users/secrets.${id}.yaml`]) + await this.doDeployment(aplRecord) return user } async deleteUser(id: string): Promise { - const filePath = getResourceFilePath('AplUser', id) - const aplObject = this.fileStore.get(filePath) - if (!aplObject) { + const existingData = await getUserSecretData(id, this.fileStore) + if (!existingData) { throw new NotExistError(`User ${id} not found`) } - const user = aplObject.spec as User - if (user.email === env.DEFAULT_PLATFORM_ADMIN_EMAIL) { + if (existingData.email === env.DEFAULT_PLATFORM_ADMIN_EMAIL) { throw new ForbiddenError('Cannot delete the default platform admin user') } - await this.deleteUserFile(id) - await this.doDeleteDeployment([filePath]) + // Remove SealedSecret manifest from git + const sealedSecretPath = `env/manifests/namespaces/apl-users/sealedsecrets/${id}.yaml` + await this.git.removeFile(sealedSecretPath) + + // Also remove legacy AplUser file if it exists + const legacyFilePath = getResourceFilePath('AplUser', id) + await this.git.removeFile(legacyFilePath) + this.fileStore.delete(legacyFilePath) + + await this.doDeleteDeployment([sealedSecretPath]) } private canTeamAdminUpdateUserTeams(sessionUser: SessionUser, existingUser: User, updatedUserTeams: string[]) { @@ -1145,19 +1272,18 @@ export default class OtomiStack { throw new ForbiddenError("Only platform admins or team admins can modify a user's team memberships.") } - const secretFiles: string[] = [] const aplRecords: AplRecord[] = [] + const updatedUsers: Pick[] = [] for (const userData of data) { if (!userData.id) { throw new NotExistError(`User ${userData.id} not found`) } - const filePath = getResourceFilePath('AplUser', userData.id) - const aplObject = this.fileStore.get(filePath) - if (!aplObject) { + const existingData = await getUserSecretData(userData.id, this.fileStore) + if (!existingData) { throw new NotExistError(`User ${userData.id} not found`) } - const existingUser = aplObject.spec as User + const existingUser = userSecretDataToUser(existingData) if ( !sessionUser.isPlatformAdmin && @@ -1170,17 +1296,13 @@ export default class OtomiStack { const updatedUser: User = { ...existingUser, teams: userData.teams } const aplRecord = await this.saveUser(updatedUser) - secretFiles.push(`${this.getRepoPath()}/env/users/secrets.${userData.id}.yaml`) aplRecords.push(aplRecord) + updatedUsers.push({ id: updatedUser.id!, teams: updatedUser.teams || [] }) } - await this.doDeployments(aplRecords, true, secretFiles) + await this.doDeployments(aplRecords) - const users = aplRecords.map((aplRecord: AplRecord) => ({ - id: aplRecord.content.spec.id, - teams: aplRecord.content.spec.teams || [], - })) - return users + return updatedUsers } getTeamCodeRepos(teamId: string): CodeRepo[] { @@ -1221,7 +1343,7 @@ export default class OtomiStack { const teamObject = toTeamObject(teamId, data) const aplRecord = await this.saveTeamConfigItem(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplCodeRepoResponse } @@ -1258,7 +1380,7 @@ export default class OtomiStack { const teamObject = buildTeamObject(existing, updatedSpec) const aplRecord = await this.saveTeamConfigItem(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplCodeRepoResponse } @@ -1271,7 +1393,7 @@ export default class OtomiStack { if (!codeRepoName) return ['HEAD'] const coderepo = this.getCodeRepo(teamId, codeRepoName) const { repositoryUrl, secret: secretName } = coderepo - const { cluster } = this.getSettings(['cluster']) + const { cluster } = await this.getSettings(['cluster']) try { let sshPrivateKey = '', username = '', @@ -1334,23 +1456,23 @@ export default class OtomiStack { } async getInternalRepoUrls(teamId: string): Promise { - if (env.isDev || !teamId || teamId === 'admin') return [] + if ((env.isDev && !(await isK8sReachable())) || !teamId || teamId === 'admin') return [] const gitea = this.getApp('gitea') if (!gitea?.values?.enabled) return [] - const { cluster, otomi } = this.getSettings(['cluster', 'otomi']) - const username = (otomi?.git?.username ?? '') as string - const password = (otomi?.git?.password ?? '') as string + const { cluster } = await this.getSettings(['cluster']) + const username = gitea.values?.adminUsername as string + const password = gitea.values?.adminPassword as string const orgName = `team-${teamId}` const domainSuffix = cluster?.domainSuffix const internalRepoUrls = (await getGiteaRepoUrls(username, password, orgName, domainSuffix)) || [] return internalRepoUrls } - getDashboard(teamId: string): Array { + async getDashboard(teamId: string): Promise> { const codeRepos = teamId ? this.getTeamAplCodeRepos(teamId) : this.getAllCodeRepos() const builds = teamId ? this.getTeamAplBuilds(teamId) : this.getAllBuilds() const workloads = teamId ? this.getTeamAplWorkloads(teamId) : this.getAllWorkloads() - const services = teamId ? this.getTeamAplServices(teamId) : this.getAllServices() + const services = teamId ? this.getTeamAplServices(teamId) : await this.getAllServices() const secrets = teamId ? this.getAplSealedSecrets(teamId) : this.getAllAplSealedSecrets() const netpols = teamId ? this.getTeamAplNetpols(teamId) : this.getAllNetpols() @@ -1403,7 +1525,7 @@ export default class OtomiStack { const teamObject = toTeamObject(teamId, data) const aplRecord = await this.saveTeamConfigItem(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplBuildResponse } @@ -1440,7 +1562,7 @@ export default class OtomiStack { const teamObject = buildTeamObject(existing, updatedSpec) const aplRecord = await this.saveTeamConfigItem(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplBuildResponse } @@ -1511,7 +1633,7 @@ export default class OtomiStack { const teamObject = buildTeamObject(existing, updatedSpec) const aplRecord = await this.saveTeamConfigItem(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplPolicyResponse } @@ -1530,7 +1652,7 @@ export default class OtomiStack { SUB: sessionUser.sub, } try { - const { cluster } = this.getSettings(['cluster']) + const { cluster } = await this.getSettings(['cluster']) variables.FQDN = cluster?.domainSuffix || '' } catch (error) { debug('Error getting cluster settings for cloudtty:', error.message) @@ -1627,7 +1749,7 @@ export default class OtomiStack { keepLocalClone: boolean = false, forceRefresh: boolean = false, ): Promise<{ url: string; helmCharts: any; catalog: any; chartsPath?: string }> { - const { cluster } = this.getSettings(['cluster']) + const { cluster } = await this.getSettings(['cluster']) try { const { helmCharts, catalog } = await fetchWorkloadCatalog( url, @@ -1663,7 +1785,7 @@ export default class OtomiStack { const aplRecord = await this.saveCatalog(data) - await this.doDeployments([aplRecord], false) + await this.doDeployments([aplRecord]) const { repositoryUrl, branch, name, chartsPath } = data.spec void this.getBYOWorkloadCatalog(repositoryUrl, branch, name, chartsPath as string | undefined, true).catch((e) => debug(`Unable to warm cache for catalog ${data.spec.name}`, e), @@ -1690,7 +1812,7 @@ export default class OtomiStack { const aplRecord = await this.saveCatalog(platformObject) const catalogResponse = aplRecord.content as AplCatalogResponse - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) try { const { repositoryUrl, branch, name: catalogName, chartsPath } = catalogResponse.spec void this.getBYOWorkloadCatalog(repositoryUrl, branch, catalogName, chartsPath as string | undefined, true) @@ -1769,7 +1891,7 @@ export default class OtomiStack { ): Promise<{ url: string; branch: string; chart: any | null; chartsPath?: string }> { const catalog = this.getAplCatalog(name) const { repositoryUrl, branch, chartsPath } = catalog.spec - const { cluster } = this.getSettings(['cluster']) + const { cluster } = await this.getSettings(['cluster']) const encodedCatalogName = encodeURIComponent(catalog.spec.name) const encodedBranch = encodeURIComponent(branch) const helmChartsDir = `${env.CATALOG_CACHE_PATH}/${encodedCatalogName}/${encodedBranch}` @@ -1798,7 +1920,7 @@ export default class OtomiStack { const localHelmChartsDir = `/tmp/otomi/charts/${uuid}` const helmChartCatalogUrl = env.HELM_CHART_CATALOG const { user, email } = this.git - const { cluster } = this.getSettings(['cluster']) + const { cluster } = await this.getSettings(['cluster']) try { await sparseCloneChart( @@ -1877,7 +1999,7 @@ export default class OtomiStack { data.spec.values || '{}', true, ) - await this.doDeployments([aplRecord, valuesAplRecord], false) + await this.doDeployments([aplRecord, valuesAplRecord]) return aplRecord.content as AplWorkloadResponse } @@ -1919,9 +2041,9 @@ export default class OtomiStack { const workloadResponse = aplRecord.content as AplWorkloadResponse if (data.spec && 'values' in data.spec) { const valuesAplRecord = await this.saveTeamWorkloadValues(teamId, name, data.spec.values!) - await this.doDeployments([aplRecord, valuesAplRecord], false) + await this.doDeployments([aplRecord, valuesAplRecord]) } else { - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) } return workloadResponse } @@ -1943,7 +2065,7 @@ export default class OtomiStack { spec: updatedSpec as AplWorkloadResponse['spec'], } const aplRecord = await this.saveTeamWorkloadValues(teamId, name, updatedSpec.values) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return merge(pick(getV1ObjectFromApl(workload), ['id', 'teamId', 'name']), { values: data.values || undefined, }) as WorkloadValues @@ -1954,8 +2076,8 @@ export default class OtomiStack { return { teamId, name, values: workload as any } } - getAllServices(): Service[] { - return this.getAllAplServices().map((service) => this.transformService(service) as Service) + async getAllServices(): Promise { + return Promise.all(this.getAllAplServices().map((service) => this.transformService(service) as Promise)) } getAllAplServices(): AplServiceResponse[] { @@ -1963,8 +2085,10 @@ export default class OtomiStack { return Array.from(files.values()) as AplServiceResponse[] } - getTeamServices(teamId: string): Service[] { - return this.getTeamAplServices(teamId).map((service) => this.transformService(service) as Service) + async getTeamServices(teamId: string): Promise { + return Promise.all( + this.getTeamAplServices(teamId).map((service) => this.transformService(service) as Promise), + ) } getTeamAplServices(teamId: string): AplServiceResponse[] { @@ -1977,7 +2101,7 @@ export default class OtomiStack { teamId, getAplObjectFromV1('AplTeamService', this.convertDbServiceToValues(data)) as AplServiceRequest, ) - return this.transformService(newService) as Service + return (await this.transformService(newService)) as Service } async createAplService(teamId: string, data: AplServiceRequest): Promise { @@ -1989,13 +2113,13 @@ export default class OtomiStack { } const teamObject = toTeamObject(teamId, data) const aplRecord = await this.saveTeamConfigItem(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplServiceResponse } - getService(teamId: string, name: string): Service { + async getService(teamId: string, name: string): Promise { const service = this.getAplService(teamId, name) - return this.transformService(service) as Service + return (await this.transformService(service)) as Service } getAplService(teamId: string, name: string): AplServiceResponse { @@ -2024,7 +2148,7 @@ export default class OtomiStack { const teamObject = buildTeamObject(existing, updatedSpec) const aplRecord = await this.saveTeamConfigItem(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplServiceResponse } @@ -2054,12 +2178,12 @@ export default class OtomiStack { }) if (servicesFiltered.length > 0) throw new PublicUrlExists() } - async doDeployments(aplRecords: AplRecord[], encryptSecrets = true, files?: string[]): Promise { + async doDeployments(aplRecords: AplRecord[]): Promise { const rootStack = await getSessionStack() try { // Commit and push Git changes - await this.git.save(this.editor!, encryptSecrets, files) + await this.git.save(this.editor!) // Pull the latest changes to ensure we have the most recent state await rootStack.git.git.pull() @@ -2077,12 +2201,12 @@ export default class OtomiStack { } } - async doDeployment(aplRecord: AplRecord, encryptSecrets = true, files?: string[]): Promise { + async doDeployment(aplRecord: AplRecord): Promise { const rootStack = await getSessionStack() try { // Commit and push Git changes - await this.git.save(this.editor!, encryptSecrets, files) + await this.git.save(this.editor!) // Pull the latest changes to ensure we have the most recent state await rootStack.git.git.pull() @@ -2103,7 +2227,7 @@ export default class OtomiStack { try { // Commit and push Git changes - await this.git.save(this.editor!, false) + await this.git.save(this.editor!) // Pull the latest changes to ensure we have the most recent state await rootStack.git.git.pull() @@ -2219,7 +2343,7 @@ export default class OtomiStack { } async getK8sServices(teamId: string): Promise> { - if (env.isDev) return [] + if (env.isDev && !(await isK8sReachable())) return [] const client = this.getApiClient() const collection: K8sService[] = [] @@ -2251,7 +2375,7 @@ export default class OtomiStack { this.getTeam(teamId) // will throw if not existing const { cluster: { name, apiName = `otomi-${name}`, apiServer }, - } = this.getSettings(['cluster']) as Record + } = (await this.getSettings(['cluster'])) as Record if (!apiServer) throw new ValidationError('Missing configuration value: cluster.apiServer') const client = this.getApiClient() const namespace = `team-${teamId}` @@ -2330,7 +2454,7 @@ export default class OtomiStack { throw new AlreadyExists('SealedSecret name already exists') } const aplRecord = await this.saveTeamSealedSecret(teamId, data) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as unknown as SealedSecretManifestResponse } @@ -2343,7 +2467,7 @@ export default class OtomiStack { throw new AlreadyExists('SealedSecret name already exists') } const aplRecord = await this.saveNamespaceSealedSecret(namespace, data) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as unknown as SealedSecretManifestResponse } @@ -2412,7 +2536,7 @@ export default class OtomiStack { } const aplRecord = await this.saveTeamSealedSecret(teamId, updatedRequest) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as unknown as SealedSecretManifestResponse } @@ -2457,7 +2581,7 @@ export default class OtomiStack { } const aplRecord = await this.saveNamespaceSealedSecret(namespace, updatedRequest) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as unknown as SealedSecretManifestResponse } @@ -2567,7 +2691,7 @@ export default class OtomiStack { } async getSecretsFromK8s(teamId: string): Promise> { - if (env.isDev) return [] + if (env.isDev && !(await isK8sReachable())) return [] return await getTeamSecretsFromK8s(`team-${teamId}`) } @@ -2580,7 +2704,7 @@ export default class OtomiStack { const teamObject = toTeamObject(teamId, data) const aplRecord = await this.saveTeamKnowledgeBase(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplKnowledgeBaseResponse } @@ -2602,7 +2726,7 @@ export default class OtomiStack { const teamObject = buildTeamObject(existing, updatedSpec) const aplRecord = await this.saveTeamKnowledgeBase(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplKnowledgeBaseResponse } @@ -2647,7 +2771,7 @@ export default class OtomiStack { } const teamObject = toTeamObject(teamId, data) const aplRecord = await this.saveTeamConfigItem(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplAgentResponse } @@ -2669,7 +2793,7 @@ export default class OtomiStack { const teamObject = buildTeamObject(existing, updatedSpec) const aplRecord = await this.saveTeamConfigItem(teamObject) - await this.doDeployment(aplRecord, false) + await this.doDeployment(aplRecord) return aplRecord.content as AplAgentResponse } @@ -2703,96 +2827,18 @@ export default class OtomiStack { await this.git.writeFile(dbPath, databaseCR.toRecord()) } - async loadValues(): Promise>>>> { + async loadValues(): Promise { debug('Loading values') - await this.git.initSops() await this.initRepo() this.isLoaded = true } - private buildSecretObject(aplObject: AplTeamObject | AplPlatformObject, secretSpec: Record): AplObject { - return { - kind: aplObject.kind, - metadata: aplObject.metadata, - spec: omit(secretSpec, ['id', 'teamId', 'name']), - } - } - - private extractAppSecretPaths(appName: string, globalPaths: string[]): string[] { - const appPrefix = `apps.${appName}.` - return globalPaths.filter((path) => path.startsWith(appPrefix)).map((path) => path.replace(appPrefix, '')) - } - - private extractSettingsSecretPaths(kind: AplKind, globalPaths: string[]): string[] { - const settingsPrefixMap: Record = { - AplDns: 'dns.', - AplKms: 'kms.', - AplSmtp: 'smtp.', - AplIdentityProvider: 'oidc.', - AplCapabilitySet: 'otomi.', - AplAlertSet: 'alerts.', - AplObjectStorage: 'obj.', - } - - const prefix = settingsPrefixMap[kind] - if (!prefix) return [] - - return globalPaths.filter((path) => path.startsWith(prefix)).map((path) => path.replace(prefix, '')) - } - - private extractTeamSecretPaths(globalPaths: string[]): string[] { - // Team paths use pattern: teamConfig.patternProperties.^[a-z0-9]([-a-z0-9]*[a-z0-9])+$.settings.{field} - const teamPattern = 'teamConfig.patternProperties.^[a-z0-9]([-a-z0-9]*[a-z0-9])+$.settings.' - - return globalPaths.filter((path) => path.startsWith(teamPattern)).map((path) => path.replace(teamPattern, '')) - } - - private async saveWithSecrets( - aplObject: AplTeamObject | AplPlatformObject, - secretPaths: string[], - ): Promise { - const secretData = {} - const specWithoutSecrets = cloneDeep(aplObject.spec) - secretPaths.forEach((secretPath) => { - const secretValue = get(aplObject.spec, secretPath) - if (secretValue) { - set(secretData, secretPath, secretValue) - unset(specWithoutSecrets, secretPath) - } - }) - - // Determine file path and save using appropriate FileStore method - let filePath: string - if ('labels' in aplObject.metadata && 'apl.io/teamId' in aplObject.metadata.labels) { - // Store full object with secrets. - // TODO check if this is needed. - filePath = this.fileStore.setTeamResource(aplObject as AplTeamObject) - } else { - // Store full object with secrets. - // TODO check if this is needed. - filePath = this.fileStore.setPlatformResource(aplObject as AplPlatformObject) - } - - // Write main file - await this.git.writeFile(filePath, { ...aplObject, spec: specWithoutSecrets }) - - // Write secrets file if there are any secrets - if (Object.keys(secretData).length > 0) { - const secretFilePath = getSecretFilePath(filePath) - // Build proper AplObject structure for secret file - const secretObject = this.buildSecretObject(aplObject, secretData) - await this.git.writeFile(secretFilePath, secretObject) - } - - return { filePath, content: aplObject } - } async saveAppToggle(app: AplObject): Promise { - const globalPaths = getSecretPaths() - const appSecretPaths = this.extractAppSecretPaths(app.metadata.name, globalPaths) - await this.saveWithSecrets(app, appSecretPaths) + const filePath = this.fileStore.setPlatformResource(app as AplPlatformObject) + await this.git.writeFile(filePath, app) } - async saveAdminApp(app: App, secretPaths?: string[]): Promise { + async saveAdminApp(app: App): Promise { const { id, enabled, values, rawValues } = app const spec: Record = { ...(values || {}), @@ -2807,28 +2853,35 @@ export default class OtomiStack { } const aplPlatformObject = buildPlatformObject('AplApp', id, spec) - - const globalPaths = secretPaths ?? getSecretPaths() - const appSecretPaths = this.extractAppSecretPaths(id, globalPaths) - - await this.saveWithSecrets(aplPlatformObject, appSecretPaths) + const filePath = this.fileStore.setPlatformResource(aplPlatformObject) + await this.git.writeFile(filePath, aplPlatformObject) } - async saveSettings(secretPaths?: string[]): Promise { - const settings = cloneDeep(this.getSettings()) as Record> - settings.otomi.nodeSelector = arrayToObject(settings.otomi.nodeSelector as []) + async saveSettings(): Promise { + // Read raw settings from fileStore (without sealed secret merging) + // to avoid writing encrypted ciphertext into settings YAML files + const settings: Record> = {} + const settingsFileMaps = getSettingsFileMaps(this.getRepoPath()) + + for (const [name, fileMap] of settingsFileMaps.entries()) { + const files = this.fileStore.getPlatformResourcesByKind(fileMap.kind) + for (const [, content] of files) { + settings[name] = cloneDeep(content?.spec || content) + } + } - // Get all settings file maps - const settingsFileMaps = getSettingsFileMaps('') - const globalPaths = secretPaths ?? getSecretPaths() + // Transform otomi nodeSelector from array back to object for disk storage + if (settings.otomi?.nodeSelector && Array.isArray(settings.otomi.nodeSelector)) { + settings.otomi.nodeSelector = arrayToObject(settings.otomi.nodeSelector as []) + } // Save each setting as a separate AplPlatformObject for (const [settingName, fileMap] of settingsFileMaps.entries()) { const settingValue = settings[settingName] if (settingValue) { const aplPlatformObject = buildPlatformObject(fileMap.kind, settingName, settingValue) - const settingsSecretPaths = this.extractSettingsSecretPaths(fileMap.kind, globalPaths) - await this.saveWithSecrets(aplPlatformObject, settingsSecretPaths) + const filePath = this.fileStore.setPlatformResource(aplPlatformObject) + await this.git.writeFile(filePath, aplPlatformObject) } } } @@ -2839,15 +2892,16 @@ export default class OtomiStack { if (!user.id) { throw new Error('User id not set') } - const aplPlatformObject = buildPlatformObject('AplUser', user.id, user as unknown as Record) - const filePath = this.fileStore.setPlatformResource(aplPlatformObject) - // Save all values to secrets files as users do not have main file - const secretObject = this.buildSecretObject(aplPlatformObject, user as unknown as Record) - const secretFilePath = getSecretFilePath(filePath) - await this.git.writeFile(secretFilePath, secretObject) + // Write SealedSecret manifest with all user fields encrypted + const sealedSecretYaml = await createUserSealedSecret(user) + const sealedSecretPath = `env/manifests/namespaces/apl-users/sealedsecrets/${user.id}.yaml` + await this.git.writeTextFile(sealedSecretPath, sealedSecretYaml) + + // Store the actual SealedSecret manifest in the fileStore so it stays in sync with disk + const content = parseYaml(sealedSecretYaml) as unknown as AplObject - return { filePath, content: aplPlatformObject } + return { filePath: sealedSecretPath, content } } async deleteUserFile(userId: string): Promise { @@ -2855,24 +2909,17 @@ export default class OtomiStack { const filePath = getResourceFilePath('AplUser', userId) this.fileStore.delete(filePath) - await this.git.removeFile(filePath) - - const secretFilePath = getSecretFilePath(filePath) - const secretExists = await this.git.fileExists(secretFilePath) - if (secretExists) { - await this.git.removeFile(secretFilePath) - } } - async saveTeam(aplTeamObject: AplTeamObject, secretPaths?: string[]): Promise { + async saveTeam(aplTeamObject: AplTeamObject): Promise { const teamId = aplTeamObject.metadata.labels['apl.io/teamId'] debug(`Saving team ${teamId}`) - const globalPaths = secretPaths ?? getSecretPaths() - const teamSecretPaths = this.extractTeamSecretPaths(globalPaths) + const filePath = this.fileStore.setTeamResource(aplTeamObject) + await this.git.writeFile(filePath, aplTeamObject) - return await this.saveWithSecrets(aplTeamObject, teamSecretPaths) + return { filePath, content: aplTeamObject } } async deleteTeamObjects(name: string): Promise { @@ -2890,7 +2937,7 @@ export default class OtomiStack { return filePaths } - transformService(service: AplServiceResponse): Record { + async transformService(service: AplServiceResponse): Promise> { const serviceSpec = service.spec const serviceMeta = { name: service.metadata.name, @@ -2912,7 +2959,7 @@ export default class OtomiStack { ] const inService = omit(serviceSpec, publicIngressFields) - const { cluster, dns } = this.getSettings(['cluster', 'dns']) + const { cluster, dns } = await this.getSettings(['cluster', 'dns']) const managedByKnative = service.spec.ksvc?.predeployed ? true : false const url = getServiceUrl({ domain: serviceSpec.domain, @@ -2965,8 +3012,8 @@ export default class OtomiStack { } } - private getVersions(currentSha: string): Record { - const { otomi } = this.getSettings(['otomi']) + private async getVersions(currentSha: string): Promise> { + const { otomi } = await this.getSettings(['otomi']) return { core: otomi?.version ?? env.VERSIONS.core, api: env.VERSIONS.api ?? process.env.npm_package_version!, @@ -2979,7 +3026,7 @@ export default class OtomiStack { const rootStack = await getSessionStack() const valuesSchema = await getValuesSchema() const currentSha = rootStack.git.commitSha - const { obj } = this.getSettings(['obj']) + const { obj } = await this.getSettings(['obj']) let regions try { regions = await getRegions() @@ -3005,7 +3052,7 @@ export default class OtomiStack { objStorageApps: env.OBJ_STORAGE_APPS, objStorageRegions, }, - versions: this.getVersions(currentSha), + versions: await this.getVersions(currentSha), valuesSchema, } return data diff --git a/src/utils.ts b/src/utils.ts index 8ae3ac5cf..6f58d3e33 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,23 +1,19 @@ -import retry from 'async-retry' -import axios from 'axios' +import $RefParser, { JSONSchema } from '@apidevtools/json-schema-ref-parser' import cleanDeep, { CleanOptions } from 'clean-deep' import Debug from 'debug' import { pathExists } from 'fs-extra' import { lstat, readdir, readFile, realpath } from 'fs/promises' import { isArray, isEmpty, memoize, mergeWith, omit } from 'lodash' import cloneDeep from 'lodash/cloneDeep' -import { isAbsolute, relative, resolve } from 'path' +import { isAbsolute, join, relative, resolve } from 'path' import { Cluster, Dns } from 'src/otomi-models' import { parse, stringify } from 'yaml' -import { BASEURL } from './constants' -import { cleanEnv, GIT_PASSWORD, STARTUP_RETRY_COUNT, STARTUP_RETRY_INTERVAL_MS } from './validators' +import { cleanEnv, GIT_PASSWORD } from './validators' const debug = Debug('otomi:utils') const env = cleanEnv({ GIT_PASSWORD, - STARTUP_RETRY_COUNT, - STARTUP_RETRY_INTERVAL_MS, }) export function arrayToObject(array: [] = [], keyName = 'name', keyValue = 'value'): Record { @@ -88,30 +84,15 @@ export async function loadRawYaml(path: string) { return rawFile as any } -const valuesSchemaEndpointUrl = `${BASEURL}/apl/schema` let valuesSchema: Record export const getValuesSchema = async (): Promise> => { - debug('Fetching values schema from tools server...') - - const res = await retry( - async () => { - try { - return await axios.get(valuesSchemaEndpointUrl) - } catch (error: any) { - debug(`Tools server not ready yet (${error.code}), retrying...`) - throw error - } - }, - { - retries: env.STARTUP_RETRY_COUNT, - minTimeout: env.STARTUP_RETRY_INTERVAL_MS, - maxTimeout: env.STARTUP_RETRY_INTERVAL_MS, - }, - ) - - debug('Values schema fetched successfully') - valuesSchema = omit(res.data, ['definitions']) + debug('Loading values schema from local file...') + const schemaPath = join(__dirname, 'values-schema.yaml') + const schema = await loadYaml(schemaPath) + const derefSchema = await $RefParser.dereference(schema as JSONSchema) + valuesSchema = omit(derefSchema as Record, ['definitions']) + debug('Values schema loaded successfully') return valuesSchema } diff --git a/src/utils/sealedSecretUtils.test.ts b/src/utils/sealedSecretUtils.test.ts new file mode 100644 index 000000000..1b3430fe8 --- /dev/null +++ b/src/utils/sealedSecretUtils.test.ts @@ -0,0 +1,200 @@ +import { extractSecretPaths, extractSettingsSecrets, removeSettingsSecrets } from './sealedSecretUtils' + +describe('extractSecretPaths', () => { + it('finds x-secret in simple properties', () => { + const schema = { + properties: { + adminPassword: { type: 'string', 'x-secret': '' }, + name: { type: 'string' }, + }, + } + expect(extractSecretPaths(schema)).toEqual(['adminPassword']) + }) + + it('finds x-secret in nested properties', () => { + const schema = { + properties: { + git: { + type: 'object', + properties: { + repoUrl: { type: 'string' }, + password: { type: 'string', 'x-secret': '{{ randAlphaNum 20 }}' }, + }, + }, + globalPullSecret: { + properties: { + username: { type: 'string' }, + password: { type: 'string', 'x-secret': '' }, + }, + }, + }, + } + const paths = extractSecretPaths(schema) + expect(paths).toContain('git.password') + expect(paths).toContain('globalPullSecret.password') + expect(paths).not.toContain('git.repoUrl') + }) + + it('finds x-secret through oneOf branches', () => { + const schema = { + properties: { + provider: { + oneOf: [ + { + properties: { + aws: { + properties: { + credentials: { + properties: { + secretKey: { type: 'string', 'x-secret': '' }, + accessKey: { type: 'string', 'x-secret': '' }, + }, + }, + }, + }, + }, + }, + { + properties: { + digitalocean: { + properties: { + apiToken: { type: 'string', 'x-secret': '' }, + }, + }, + }, + }, + ], + }, + }, + } + const paths = extractSecretPaths(schema) + expect(paths).toContain('provider.aws.credentials.secretKey') + expect(paths).toContain('provider.aws.credentials.accessKey') + expect(paths).toContain('provider.digitalocean.apiToken') + }) + + it('finds x-secret in definitions', () => { + const schema = { + definitions: { + accessKey: { type: 'string', 'x-secret': '' }, + region: { type: 'string' }, + }, + } + const paths = extractSecretPaths(schema) + expect(paths).toContain('accessKey') + expect(paths).not.toContain('region') + }) + + it('returns empty array for schema without x-secret', () => { + const schema = { + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + } + expect(extractSecretPaths(schema)).toEqual([]) + }) + + it('deduplicates paths', () => { + const schema = { + properties: { + token: { type: 'string', 'x-secret': '' }, + }, + allOf: [ + { + properties: { + token: { type: 'string', 'x-secret': '' }, + }, + }, + ], + } + const paths = extractSecretPaths(schema) + expect(paths.filter((p) => p === 'token')).toHaveLength(1) + }) + + it('handles otomi schema shape', () => { + const schema = { + properties: { + adminPassword: { type: 'string', 'x-secret': '{{ randAlphaNum 20 }}' }, + isPreInstalled: { type: 'boolean' }, + globalPullSecret: { + properties: { + username: { type: 'string' }, + password: { type: 'string', 'x-secret': '' }, + email: { type: 'string' }, + }, + }, + git: { + type: 'object', + properties: { + repoUrl: { type: 'string' }, + password: { type: 'string', 'x-secret': '{{ randAlphaNum 20 }}' }, + email: { type: 'string' }, + }, + }, + }, + } + const paths = extractSecretPaths(schema) + expect(paths).toContain('adminPassword') + expect(paths).toContain('globalPullSecret.password') + expect(paths).toContain('git.password') + expect(paths).not.toContain('isPreInstalled') + expect(paths).not.toContain('globalPullSecret.username') + expect(paths).not.toContain('git.repoUrl') + }) +}) + +describe('extractSettingsSecrets', () => { + it('extracts non-empty secret values', () => { + const data = { + adminPassword: 'secret123', + git: { repoUrl: 'https://example.com', password: 'gitpass' }, + globalPullSecret: { username: 'user', password: 'pullpass' }, + } + const paths = ['adminPassword', 'git.password', 'globalPullSecret.password'] + const secrets = extractSettingsSecrets(paths, data) + expect(secrets).toEqual({ + adminPassword: 'secret123', + git_password: 'gitpass', + globalPullSecret_password: 'pullpass', + }) + }) + + it('skips empty and missing values', () => { + const data = { + adminPassword: '', + git: { repoUrl: 'https://example.com' }, + } + const paths = ['adminPassword', 'git.password', 'globalPullSecret.password'] + const secrets = extractSettingsSecrets(paths, data) + expect(secrets).toEqual({}) + }) + + it('returns empty record when no paths match', () => { + const data = { name: 'test' } + const secrets = extractSettingsSecrets(['nonexistent'], data) + expect(secrets).toEqual({}) + }) +}) + +describe('removeSettingsSecrets', () => { + it('removes secret values from data', () => { + const data = { + adminPassword: 'secret123', + name: 'test', + git: { repoUrl: 'https://example.com', password: 'gitpass' }, + } + const paths = ['adminPassword', 'git.password'] + removeSettingsSecrets(paths, data) + expect(data).toEqual({ + name: 'test', + git: { repoUrl: 'https://example.com' }, + }) + }) + + it('handles missing paths gracefully', () => { + const data = { name: 'test' } + removeSettingsSecrets(['nonexistent', 'deeply.nested.path'], data) + expect(data).toEqual({ name: 'test' }) + }) +}) diff --git a/src/utils/sealedSecretUtils.ts b/src/utils/sealedSecretUtils.ts index 7e552d098..18c7a0fde 100644 --- a/src/utils/sealedSecretUtils.ts +++ b/src/utils/sealedSecretUtils.ts @@ -1,11 +1,16 @@ +import { encryptSecretItem } from '@linode/kubeseal-encrypt' import { X509Certificate } from 'crypto' -import { isEmpty } from 'lodash' -import { SealedSecretManifestRequest, SealedSecretManifestResponse } from 'src/otomi-models' +import Debug from 'debug' +import { get, isEmpty, unset } from 'lodash' +import { SealedSecretManifestRequest, SealedSecretManifestResponse, User } from 'src/otomi-models' import { cleanEnv } from 'src/validators' +import { stringify as stringifyYaml } from 'yaml' import { ValidationError } from '../error' -import { getSealedSecretsCertificate } from '../k8s_operations' +import { getSealedSecretsCertificate, isK8sReachable, UserSecretData } from '../k8s_operations' +const debug = Debug('otomi:sealedSecretUtils') const env = cleanEnv({}) + export function sealedSecretManifest( teamId: string | undefined, data: SealedSecretManifestRequest, @@ -86,7 +91,7 @@ function getPEM(certificate): string { export async function getSealedSecretsPEM(): Promise { try { - if (env.isDev) return '' + if (env.isDev && !(await isK8sReachable())) return '' else { const certificate = await getSealedSecretsCertificate() if (!certificate) { @@ -99,3 +104,192 @@ export async function getSealedSecretsPEM(): Promise { throw new ValidationError('SealedSecrets certificate not found') } } + +// Kubeseal ciphertext is a long base64 string (typically 300+ chars). +// Plain text values are shorter and likely not valid base64. +function isEncryptedValue(value: string): boolean { + if (value.length < 200) return false + return /^[A-Za-z0-9+/=]+$/.test(value) +} + +export async function encryptSecretValue(pem: string, namespace: string, value: string): Promise { + return encryptSecretItem(pem, namespace, value) +} + +/** + * Ensures all encryptedData values are encrypted. + * If any values appear to be plain text, encrypts them server-side as a fallback. + */ +export async function ensureEncryptedData( + encryptedData: Record, + teamId: string, +): Promise> { + const namespace = `team-${teamId}` + const plainTextKeys = Object.entries(encryptedData).filter(([, value]) => !isEncryptedValue(value)) + + if (plainTextKeys.length === 0) return encryptedData + + debug(`Encrypting ${plainTextKeys.length} plain text value(s) server-side for namespace ${namespace}`) + const pem = await getSealedSecretsPEM() + if (!pem) throw new ValidationError('Cannot encrypt: SealedSecrets PEM not available') + + const result = { ...encryptedData } + for (const [key, value] of plainTextKeys) { + result[key] = await encryptSecretValue(pem, namespace, value) + } + return result +} + +export function sealedSecretToUserData(manifest: SealedSecretManifestResponse): UserSecretData { + const data = manifest.spec.encryptedData + return { + id: manifest.metadata.name, + email: data.email || '', + firstName: data.firstName || '', + lastName: data.lastName || '', + initialPassword: data.initialPassword || '', + isPlatformAdmin: data.isPlatformAdmin === 'true', + isTeamAdmin: data.isTeamAdmin === 'true', + teams: typeof data.teams === 'string' ? JSON.parse(data.teams) : data.teams || [], + } as UserSecretData +} + +/** + * Creates a SealedSecret manifest for a platform-level secret (not team-scoped). + * Used for secrets in apl-secrets, apl-users, and other platform namespaces. + */ +export async function createPlatformSealedSecretManifest( + name: string, + namespace: string, + data: Record, +): Promise { + const pem = await getSealedSecretsPEM() + + // In dev mode (no PEM), store values as plain text + const encryptedData: Record = {} + if (pem) { + for (const [key, value] of Object.entries(data)) { + encryptedData[key] = await encryptSecretValue(pem, namespace, value) + } + } else { + Object.assign(encryptedData, data) + } + + const manifest = { + apiVersion: 'bitnami.com/v1alpha1', + kind: 'SealedSecret', + metadata: { + annotations: { + 'sealedsecrets.bitnami.com/namespace-wide': 'true', + }, + name, + namespace, + }, + spec: { + encryptedData, + template: { + immutable: false, + metadata: { name, namespace }, + type: 'kubernetes.io/opaque', + }, + }, + } + + return stringifyYaml(manifest, undefined, { indent: 4, sortMapEntries: true }) +} + +/** + * Creates a SealedSecret manifest YAML for a user in the apl-users namespace. + * All user fields are encrypted as individual keys. + */ +export async function createUserSealedSecret(user: User): Promise { + const namespace = 'apl-users' + const name = user.id as string + + const data: Record = { + email: user.email, + firstName: user.firstName || '', + lastName: user.lastName || '', + initialPassword: user.initialPassword || '', + isPlatformAdmin: String(user.isPlatformAdmin || false), + isTeamAdmin: String(user.isTeamAdmin || false), + teams: JSON.stringify(user.teams || []), + } + + return createPlatformSealedSecretManifest(name, namespace, data) +} + +/** + * Walks a JSON schema and returns dot-paths to all properties marked with `x-secret`. + * Schema keywords (properties, items, anyOf, etc.) and numeric array indices are stripped from paths. + */ +export function extractSecretPaths(schema: Record, prefix = ''): string[] { + const paths: string[] = [] + if (!schema || typeof schema !== 'object') return paths + + if (schema.properties) { + const properties = schema.properties as Record> + for (const [key, value] of Object.entries(properties)) { + const childPath = prefix ? `${prefix}.${key}` : key + const prop = value as Record + if (prop && 'x-secret' in prop) { + paths.push(childPath) + } + paths.push(...extractSecretPaths(prop, childPath)) + } + } + + if (schema.definitions) { + const definitions = schema.definitions as Record> + for (const [key, value] of Object.entries(definitions)) { + const childPath = prefix ? `${prefix}.${key}` : key + const def = value as Record + if (def && 'x-secret' in def) { + paths.push(childPath) + } + paths.push(...extractSecretPaths(def, childPath)) + } + } + + if (schema.items) { + // items shares the same prefix (array items don't add path segments) + paths.push(...extractSecretPaths(schema.items as Record, prefix)) + } + + for (const keyword of ['anyOf', 'allOf', 'oneOf']) { + if (Array.isArray(schema[keyword])) { + for (const branch of schema[keyword]) { + paths.push(...extractSecretPaths(branch as Record, prefix)) + } + } + } + + return [...new Set(paths)] +} + +/** + * Extracts secret values from a settings data object at the given dot-paths. + * Returns a flat record mapping dot-paths to their string values (only non-empty). + */ +export function extractSettingsSecrets(secretPaths: string[], data: Record): Record { + const secrets: Record = {} + for (const path of secretPaths) { + const value = get(data, path) + if (value !== undefined && value !== null && value !== '') { + const dataKey = path.replace(/\./g, '_') + secrets[dataKey] = String(value) + } + } + return secrets +} + +/** + * Removes secret values from a settings data object at the given dot-paths. + * Mutates the data object in place and returns it. + */ +export function removeSettingsSecrets(secretPaths: string[], data: Record): Record { + for (const path of secretPaths) { + unset(data, path) + } + return data +} diff --git a/src/utils/userUtils.ts b/src/utils/userUtils.ts index fc132afcb..af3795afe 100644 --- a/src/utils/userUtils.ts +++ b/src/utils/userUtils.ts @@ -1,5 +1,9 @@ import axios from 'axios' -import { ROOT_KEYCLOAK_USER, cleanEnv } from 'src/validators' +import { SealedSecretManifestResponse, User } from 'src/otomi-models' +import { cleanEnv, ROOT_KEYCLOAK_USER } from 'src/validators' +import { FileStore } from '../fileStore/file-store' +import { getUserSecretFromK8s, isK8sReachable, listUserSecretsFromK8s, UserSecretData } from '../k8s_operations' +import { sealedSecretToUserData } from './sealedSecretUtils' const env = cleanEnv({ ROOT_KEYCLOAK_USER, @@ -62,6 +66,19 @@ export async function getKeycloakUsers( } } +export function userSecretDataToUser(data: UserSecretData): User { + return { + id: data.id, + email: data.email, + firstName: data.firstName, + lastName: data.lastName, + initialPassword: data.initialPassword, + isPlatformAdmin: data.isPlatformAdmin, + isTeamAdmin: data.isTeamAdmin, + teams: data.teams, + } as User +} + // gitea username blacklist and validation // https://github.com/go-gitea/gitea/blob/b8b856c7455166ef580d83a29b57c9b877d052b4/models/user/user.go#L563 const reservedUsernames = [ @@ -130,3 +147,21 @@ export function isValidUsername(username: string): { valid: boolean; error: stri return { valid: true, error: null } } + +export async function listUserSecretData( + getAplNamespaceSealedSecrets: (namespace: string) => SealedSecretManifestResponse[], +): Promise { + if (env.isDev && !(await isK8sReachable())) { + return getAplNamespaceSealedSecrets('apl-users').map((m) => sealedSecretToUserData(m)) + } + return listUserSecretsFromK8s() +} + +export async function getUserSecretData(id: string, fileStore: FileStore): Promise { + if (env.isDev && !(await isK8sReachable())) { + const manifest = fileStore.getNamespaceResource('AplNamespaceSealedSecret', id, 'apl-users') + if (!manifest) return undefined + return sealedSecretToUserData(manifest as SealedSecretManifestResponse) + } + return getUserSecretFromK8s(id) +} diff --git a/src/utils/wizardUtils.test.ts b/src/utils/wizardUtils.test.ts index 340610c11..e8163c8d5 100644 --- a/src/utils/wizardUtils.test.ts +++ b/src/utils/wizardUtils.test.ts @@ -123,7 +123,7 @@ describe('ObjectStorageClient', () => { test('should return error when cluster name is undefined', async () => { const data = { apiToken: 'some-token', regionId: 'us-east', label: 'my-cluster' } - jest.spyOn(otomiStack, 'getSettings').mockReturnValue({ + jest.spyOn(otomiStack, 'getSettings').mockResolvedValue({ cluster: { domainSuffix, provider: 'linode' }, } as any) const result: ObjWizard = await otomiStack.createObjWizard(data)