Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/api/v2/namespaces/namespaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Debug from 'debug'
import { Response } from 'express'
import { OpenApiRequestExt } from 'src/otomi-models'

const debug = Debug('otomi:api:v2:namespaces')

/**
* GET /v2/namespaces
* Return namespaces that contain at least one sealedsecret
*/
export const getNamespacesWithSealedSecrets = async (req: OpenApiRequestExt, res: Response): Promise<void> => {
debug('getNamespacesWithSealedSecrets()')
const namespaces = req.otomi.getNamespacesWithSealedSecrets()
res.json(namespaces)
}
30 changes: 30 additions & 0 deletions src/api/v2/namespaces/{namespace}/sealedsecrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Debug from 'debug'
import { Response } from 'express'
import { OpenApiRequestExt, SealedSecretManifestRequest } from 'src/otomi-models'

const debug = Debug('otomi:api:v2:namespaces:sealedsecrets')

/**
* GET /v2/namespaces/{namespace}/sealedsecrets
* get all sealed secrets for namespace (SealedSecret manifest format)
*/
export const getAplNamespaceSealedSecrets = async (req: OpenApiRequestExt, res: Response): Promise<void> => {
const { namespace } = req.params
debug(`getAplNamespaceSealedSecrets(${namespace}, ...)`)
const v = await req.otomi.getAplNamespaceSealedSecrets(decodeURIComponent(namespace))
res.json(v)
}

/**
* POST /v2/namespaces/{namespace}/sealedsecrets
* Create a new sealed secret (SealedSecret manifest format)
*/
export const createAplNamespaceSealedSecret = async (req: OpenApiRequestExt, res: Response): Promise<void> => {
const { namespace } = req.params
debug(`createNamespaceSealedSecret(${namespace}, ...)`)
const v = await req.otomi.createAplNamespaceSealedSecret(
decodeURIComponent(namespace),
req.body as SealedSecretManifestRequest,
)
res.json(v)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Debug from 'debug'
import { Response } from 'express'
import { DeepPartial, OpenApiRequestExt, SealedSecretManifestRequest } from 'src/otomi-models'

const debug = Debug('otomi:api:v2:teams:sealedsecrets')

/**
* GET /v2/namespaces/{namespace}/sealedsecrets/{sealedSecretName}
* Get a specific sealed secret for namespace (SealedSecret manifest format)
*/
export const getAplNamespaceSealedSecret = async (req: OpenApiRequestExt, res: Response): Promise<void> => {
const { namespace, sealedSecretName } = req.params
debug(`getSealedSecret(${sealedSecretName}) for namespace(${namespace})`)
const data = await req.otomi.getAplNamespaceSealedSecret(
decodeURIComponent(namespace),
decodeURIComponent(sealedSecretName),
)
res.json(data)
}

/**
* PUT /v2/namespaces/{namespace}/sealedsecrets/{sealedSecretName}
* Edit a sealed secret for namespace (SealedSecret manifest format)
*/
export const editAplNamespaceSealedSecret = async (req: OpenApiRequestExt, res: Response): Promise<void> => {
const { namespace, sealedSecretName } = req.params
debug(`editSealedSecret(${sealedSecretName}) for namespace(${namespace})`)
const data = await req.otomi.editAplNamespaceSealedSecret(
decodeURIComponent(namespace),
decodeURIComponent(sealedSecretName),
req.body as SealedSecretManifestRequest,
)
res.json(data)
}

/**
* PATCH /v2/namespaces/{namespace}/sealedsecrets/{sealedSecretName}
* Partially update a sealed secret for namespace (SealedSecret manifest format)
*/
export const patchAplNamespaceSealedSecret = async (req: OpenApiRequestExt, res: Response): Promise<void> => {
const { namespace, sealedSecretName } = req.params
debug(`editSealedSecret(${sealedSecretName} for namespace(${namespace}), patch)`)
const data = await req.otomi.editAplNamespaceSealedSecret(
decodeURIComponent(namespace),
decodeURIComponent(sealedSecretName),
req.body as DeepPartial<SealedSecretManifestRequest>,
true,
)
res.json(data)
}

/**
* DELETE /v2/namespaces/{namespace}/sealedsecrets/{sealedSecretName}
* Delete a sealed secret for namespace
*/
export const deleteAplNamespaceSealedSecret = async (req: OpenApiRequestExt, res: Response): Promise<void> => {
const { namespace, sealedSecretName } = req.params
debug(`deleteSealedSecret(${sealedSecretName}) for namespace(${namespace})`)
await req.otomi.deleteAplNamespaceSealedSecret(decodeURIComponent(namespace), decodeURIComponent(sealedSecretName))
res.json({})
}
17 changes: 17 additions & 0 deletions src/fileStore/file-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ export function getFileMaps(envDir: string): Map<AplKind, FileMap> {
name: 'sealedsecrets',
})

maps.set('AplNamespaceSealedSecret', {
kind: 'SealedSecret',
envDir,
pathGlob: `${envDir}/env/namespaces/*/sealedsecrets/*.yaml`,
pathTemplate: 'env/namespaces/{namespace}/sealedsecrets/{name}.yaml',
name: 'sealedsecrets',
})

maps.set('AkamaiKnowledgeBase', {
kind: 'AkamaiKnowledgeBase',
envDir,
Expand Down Expand Up @@ -242,6 +250,15 @@ export function getResourceFilePath(kind: AplKind, name: string, teamId?: string
return fileMap.pathTemplate.replace('{teamId}', teamId || '').replace('{name}', name)
}

export function getNamespaceResourceFilePath(kind: AplKind, name: string, namespace: string): string {
const fileMap = getFileMapForKind(kind)
if (!fileMap) {
throw new Error(`Unknown kind: ${kind}`)
}

return fileMap.pathTemplate.replace('{namespace}', namespace || '').replace('{name}', name)
}

// Derive secret file path from main file path
// e.g., 'env/teams/demo/settings.yaml' -> 'env/teams/demo/secrets.settings.yaml'
// e.g., 'env/apps/harbor.yaml' -> 'env/apps/secrets.harbor.yaml'
Expand Down
93 changes: 92 additions & 1 deletion src/fileStore/file-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { stringify as stringifyYaml } from 'yaml'
import { z } from 'zod'
import { APL_KINDS, AplKind, AplObject, AplPlatformObject, AplRecord, AplTeamObject } from '../otomi-models'
import { loadRawYaml, loadYaml } from '../utils'
import { getFileMapForKind, getFileMaps, getResourceFilePath } from './file-map'
import { getFileMapForKind, getFileMaps, getNamespaceResourceFilePath, getResourceFilePath } from './file-map'

const debug = Debug('otomi:file-store')

Expand Down Expand Up @@ -173,11 +173,22 @@ export class FileStore {
return filePath
}

deleteNamespaceResource(kind: AplKind, namespace: string, name: string): string {
const filePath = getNamespaceResourceFilePath(kind, name, namespace)
this.store.delete(filePath)
return filePath
}

getPlatformResource(kind: AplKind, name: string): AplObject | undefined {
const filePath = getResourceFilePath(kind, name)
return this.store.get(filePath)
}

getNamespaceResource(kind: AplKind, name: string, namespace: string): AplObject | undefined {
const filePath = getNamespaceResourceFilePath(kind, name, namespace)
return this.store.get(filePath)
}

setPlatformResource(aplPlatformObject: AplPlatformObject): string {
const filePath = getResourceFilePath(aplPlatformObject.kind, aplPlatformObject.metadata.name)
this.store.set(filePath, aplPlatformObject)
Expand Down Expand Up @@ -237,6 +248,86 @@ export class FileStore {
return result
}

getAllNamespaceResourcesByKind(kind: AplKind): Map<string, AplObject> {
const fileMap = getFileMapForKind(kind)
if (!fileMap) throw new Error(`Unknown kind: ${kind}`)

const parts = fileMap.pathTemplate.split('{namespace}')
if (parts.length < 2) {
throw new Error(`Kind ${kind} is not namespace-scoped (missing {namespace} in pathTemplate)`)
}

// parts[0] => 'env/namespaces/'
const namespacePrefix = parts[0]

// parts[1] => '/sealedsecrets/{name}.yaml' -> '/sealedsecrets/'
const resourceDir = parts[1].replace('{name}.yaml', '') // keeps trailing slash

const result = new Map<string, AplObject>()

for (const filePath of this.store.keys()) {
if (!filePath.startsWith(namespacePrefix)) continue
if (!filePath.includes(resourceDir)) continue
if (!filePath.endsWith('.yaml')) continue

const content = this.store.get(filePath)
if (content) result.set(filePath, content)
}

return result
}

getNamespaceResourcesByKind(kind: AplKind, namespace: string): Map<string, AplObject> {
const fileMap = getFileMapForKind(kind)
if (!fileMap) throw new Error(`Unknown kind: ${kind}`)

const parts = fileMap.pathTemplate.split('{namespace}')
if (parts.length < 2) {
throw new Error(`Kind ${kind} is not namespace-scoped (missing {namespace} in pathTemplate)`)
}

const namespacePrefix = parts[0] // 'env/namespaces/'
const resourceDir = parts[1].replace('{name}.yaml', '') // '/sealedsecrets/'

const result = new Map<string, AplObject>()

// Only match this namespace:
// env/namespaces/{namespace}/sealedsecrets/*.yaml
const requiredPrefix = `${namespacePrefix}${namespace}${resourceDir}`

for (const filePath of this.store.keys()) {
if (!filePath.startsWith(requiredPrefix)) continue
if (!filePath.endsWith('.yaml')) continue

const content = this.store.get(filePath)
if (content) result.set(filePath, content)
}

return result
}

// Return namespaces that contain at least one sealedsecret
getNamespacesWithSealedSecrets(): string[] {
const prefix = 'env/namespaces/'
const segment = '/sealedsecrets/'

const namespaces = new Set<string>()

for (const filePath of this.store.keys()) {
if (!filePath.startsWith(prefix)) continue
if (!filePath.includes(segment)) continue
if (!filePath.endsWith('.yaml')) continue

// env/namespaces/{namespace}/sealedsecrets/{name}.yaml
const match = filePath.match(/^env\/namespaces\/([^/]+)\//)
const namespace = match?.[1]

if (namespace) namespaces.add(namespace)
}

return Array.from(namespaces)
}

getTeamResourcesByKindAndTeamId(kind: AplKind, teamId: string): Map<string, AplObject> {
const fileMap = getFileMapForKind(kind)
if (!fileMap) {
Expand Down
123 changes: 122 additions & 1 deletion src/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,121 @@ paths:
$ref: '#/components/responses/NotFound'
'200':
description: Successfully deleted a team sealed secret

'/v2/namespaces':
get:
operationId: getNamespacesWithSealedSecrets
x-eov-operation-handler: v2/namespaces/namespaces
description: Get namespaces that contain sealed secrets
responses:
'200':
description: List of namespaces
content:
application/json:
schema:
type: array
items:
type: string
'/v2/namespaces/{namespace}/sealedsecrets':
parameters:
- $ref: '#/components/parameters/namespaceParams'
get:
operationId: getAplNamespaceSealedSecrets
x-eov-operation-handler: v2/namespaces/{namespace}/sealedsecrets
description: Get sealed secrets from a given namespace
x-aclSchema: SealedSecret
responses:
'200':
description: Successfully obtained sealed secrets
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/SealedSecretManifestResponse'
'400':
description: Bad Request
content:
application/json:
schema:
$ref: '#/components/schemas/OpenApiValidationError'
post:
operationId: createAplNamespaceSealedSecret
x-eov-operation-handler: v2/namespaces/{namespace}/sealedsecrets
description: Create a namespace sealed secret
x-aclSchema: SealedSecret
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SealedSecretManifestRequest'
description: SealedSecret object
required: true
responses:
'400':
$ref: '#/components/responses/BadRequest'
'409':
$ref: '#/components/responses/OtomiStackError'
'200':
description: Successfully stored sealed secret configuration
content:
application/json:
schema:
$ref: '#/components/schemas/SealedSecretManifestResponse'
'/v2/namespaces/{namespace}/sealedsecrets/{sealedSecretName}':
parameters:
- $ref: '#/components/parameters/namespaceParams'
- $ref: '#/components/parameters/sealedSecretParams'
get:
operationId: getAplNamespaceSealedSecret
x-eov-operation-handler: v2/namespaces/{namespace}/sealedsecrets/{sealedSecretName}
description: Get a sealed secret from a given namespace
x-aclSchema: SealedSecret
responses:
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'200':
description: Successfully obtained sealed secret configuration
content:
application/json:
schema:
$ref: '#/components/schemas/SealedSecretManifestResponse'
put:
operationId: editAplNamespaceSealedSecret
x-eov-operation-handler: v2/namespaces/{namespace}/sealedsecrets/{sealedSecretName}
description: Edit a sealed secret from a given namespace
x-aclSchema: SealedSecret
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SealedSecretManifestRequest'
description: SealedSecret object that contains updated values
required: true
responses:
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'200':
description: Successfully edited a namespace sealed secret
content:
application/json:
schema:
$ref: '#/components/schemas/SealedSecretManifestResponse'
delete:
operationId: deleteAplNamespaceSealedSecret
x-eov-operation-handler: v2/namespaces/{namespace}/sealedsecrets/{sealedSecretName}
description: Delete a sealed secret from a given namespace
x-aclSchema: SealedSecret
responses:
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'200':
description: Successfully deleted a namespace sealed secret
'/v1/netpols':
get:
operationId: getAllNetpols
Expand Down Expand Up @@ -2893,6 +3007,13 @@ components:
required: true
schema:
type: string
namespaceParams:
name: namespace
in: path
description: Namspace to write file under in manifest
required: true
schema:
type: string
netpolParams:
name: netpolName
in: path
Expand Down
Loading
Loading