blob: 7a7de91743c3941c16e33ccb22d53fc2f8d2da77 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
import { existsSync, readFileSync, writeFileSync } from "node:fs"
import { BUILD_CONTEXT_MARKER, parseEndpoint, type Endpoint } from "./lib.ts"
const SECRET_NAME = /^[A-Z_][A-Z0-9_]*$/
const TRUSTABLE_MANAGED_ENV_NAMES = new Set([
"OPS_USER",
"OPS_PASSWORD",
"OPS_APIHOST",
"OPS_REPO",
"OPS_SKILLS",
])
export interface SecretBindingStatus {
endpoint: string
configured: boolean
}
export interface SecretBindingResult {
configured: string[]
alreadyConfigured: string[]
}
export interface SecretUnbindingResult {
removed: string[]
alreadyAbsent: string[]
}
function normalizeEnvName(secret: string): string {
const name = secret.trim()
if (!SECRET_NAME.test(name)) {
throw new Error("secret name must contain only uppercase letters, numbers, and underscores, and must not start with a number")
}
return name
}
export function normalizeSecretName(secret: string): string {
const name = normalizeEnvName(secret)
if (TRUSTABLE_MANAGED_ENV_NAMES.has(name)) {
throw new Error(`'${name}' is a Trustable-managed runtime variable and cannot be used as an application secret or action parameter`)
}
return name
}
export function envNames(path = ".env"): Set<string> {
return new Set(envValues(path).keys())
}
function envValues(path: string): Map<string, string> {
if (!existsSync(path)) return new Map()
const values = new Map<string, string>()
for (const line of readFileSync(path, "utf-8").split(/\r?\n/)) {
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/)
if (match) values.set(match[1], match[2])
}
return values
}
export function hasEnvSecret(secret: string, path = ".env"): boolean {
const name = normalizeSecretName(secret)
// WHY: this is a value-free existence probe only. Environment mutation is
// reserved for the Trustable UI and is intentionally absent from this MCP.
return Boolean(envValues(path).get(name))
}
function secretInjection(secret: string): string {
return `
#--param ${secret} "$${secret}"
def init_${secret.toLowerCase()}(args, ctx):
value = args.get("${secret}") or os.getenv("${secret}")
if not value:
raise RuntimeError("Required secret ${secret} is not configured")
setattr(ctx, "${secret}", value)
builder.append(init_${secret.toLowerCase()})`
}
function endpointLabel(endpoint: Endpoint): string {
return `${endpoint.pkg}/${endpoint.name}`
}
export function secretBindingStatus(secret: string, endpoints: string[]): SecretBindingStatus[] {
const name = normalizeSecretName(secret)
return endpoints.map((value) => {
const endpoint = parseEndpoint(value)
const configured = existsSync(endpoint.mainPath)
&& readFileSync(endpoint.mainPath, "utf-8").includes(`#--param ${name} `)
return { endpoint: endpointLabel(endpoint), configured }
})
}
/**
* Bind one secret to every endpoint only after all endpoints have passed
* validation. This avoids a half-configured authentication flow.
*/
export function bindSecret(secret: string, endpointValues: string[]): SecretBindingResult {
const name = normalizeSecretName(secret)
if (!hasEnvSecret(name)) {
throw new Error(`secret '${name}' is not configured in .env; no endpoint was changed`)
}
if (endpointValues.length === 0) {
throw new Error("at least one endpoint is required")
}
const endpoints = endpointValues.map(parseEndpoint)
const labels = endpoints.map(endpointLabel)
if (new Set(labels).size !== labels.length) {
throw new Error("endpoints must not contain duplicates")
}
const configured: string[] = []
const alreadyConfigured: string[] = []
const changes: { path: string; previous: string; next: string }[] = []
for (const endpoint of endpoints) {
if (!existsSync(endpoint.mainPath)) {
throw new Error(`endpoint not found at ${endpoint.mainPath}; no endpoint was changed`)
}
const previous = readFileSync(endpoint.mainPath, "utf-8")
if (!previous.includes(BUILD_CONTEXT_MARKER)) {
throw new Error(`marker '${BUILD_CONTEXT_MARKER}' not found in ${endpoint.mainPath}; no endpoint was changed`)
}
const label = endpointLabel(endpoint)
if (previous.includes(`#--param ${name} `)) {
alreadyConfigured.push(label)
continue
}
changes.push({
path: endpoint.mainPath,
previous,
next: previous.replace(BUILD_CONTEXT_MARKER, BUILD_CONTEXT_MARKER + secretInjection(name)),
})
configured.push(label)
}
const written: typeof changes = []
try {
for (const change of changes) {
writeFileSync(change.path, change.next)
written.push(change)
}
} catch (cause) {
for (const change of written.reverse()) {
writeFileSync(change.path, change.previous)
}
throw cause
}
return { configured, alreadyConfigured }
}
/**
* Remove only the exact wrapper block previously generated by the secret
* binding tools. This is also the supported recovery path for legacy invalid
* bindings of Trustable-managed variables such as OPS_APIHOST.
*/
export function unbindSecret(secret: string, endpointValues: string[]): SecretUnbindingResult {
const name = normalizeEnvName(secret)
if (endpointValues.length === 0) {
throw new Error("at least one endpoint is required")
}
const endpoints = endpointValues.map(parseEndpoint)
const labels = endpoints.map(endpointLabel)
if (new Set(labels).size !== labels.length) {
throw new Error("endpoints must not contain duplicates")
}
const injection = secretInjection(name)
const removed: string[] = []
const alreadyAbsent: string[] = []
const changes: { path: string; previous: string; next: string }[] = []
for (const endpoint of endpoints) {
if (!existsSync(endpoint.mainPath)) {
throw new Error(`endpoint not found at ${endpoint.mainPath}; no endpoint was changed`)
}
const previous = readFileSync(endpoint.mainPath, "utf-8")
if (!previous.includes(BUILD_CONTEXT_MARKER)) {
throw new Error(`marker '${BUILD_CONTEXT_MARKER}' not found in ${endpoint.mainPath}; no endpoint was changed`)
}
const label = endpointLabel(endpoint)
if (!previous.includes(injection)) {
alreadyAbsent.push(label)
continue
}
changes.push({
path: endpoint.mainPath,
previous,
next: previous.replace(injection, ""),
})
removed.push(label)
}
const written: typeof changes = []
try {
for (const change of changes) {
writeFileSync(change.path, change.next)
written.push(change)
}
} catch (cause) {
for (const change of written.reverse()) {
writeFileSync(change.path, change.previous)
}
throw cause
}
return { removed, alreadyAbsent }
}