forked from cerc-io/stack-orchestrator
Add $generate:type:length$ token support for K8s secrets
- Add GENERATE_TOKEN_PATTERN to detect $generate:hex:N$ and $generate:base64:N$ tokens
- Add _generate_and_store_secrets() to create K8s Secrets from spec.yml config
- Modify _write_config_file() to separate secrets from regular config
- Add env_from with secretRef to container spec in cluster_info.py
- Secrets are injected directly into containers via K8s native mechanism
This enables declarative secret generation in spec.yml:
config:
SESSION_SECRET: $generate:hex:32$
DB_PASSWORD: $generate:hex:16$
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2d3721efa4
commit
ca090d2cd5
@ -16,6 +16,8 @@
|
|||||||
import click
|
import click
|
||||||
from importlib import util
|
from importlib import util
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import base64
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import random
|
import random
|
||||||
@ -484,15 +486,99 @@ def init_operation(
|
|||||||
get_yaml().dump(spec_file_content, output_file)
|
get_yaml().dump(spec_file_content, output_file)
|
||||||
|
|
||||||
|
|
||||||
def _write_config_file(spec_file: Path, config_env_file: Path):
|
# Token pattern: $generate:hex:32$ or $generate:base64:16$
|
||||||
|
GENERATE_TOKEN_PATTERN = re.compile(r"\$generate:(\w+):(\d+)\$")
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_and_store_secrets(config_vars: dict, deployment_name: str):
|
||||||
|
"""Generate secrets for $generate:...$ tokens and store in K8s Secret.
|
||||||
|
|
||||||
|
Called by `deploy create` - generates fresh secrets and stores them.
|
||||||
|
Returns the generated secrets dict for reference.
|
||||||
|
"""
|
||||||
|
from kubernetes import client, config as k8s_config
|
||||||
|
|
||||||
|
secrets = {}
|
||||||
|
for name, value in config_vars.items():
|
||||||
|
if not isinstance(value, str):
|
||||||
|
continue
|
||||||
|
match = GENERATE_TOKEN_PATTERN.search(value)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
secret_type, length = match.group(1), int(match.group(2))
|
||||||
|
if secret_type == "hex":
|
||||||
|
secrets[name] = token_hex(length)
|
||||||
|
elif secret_type == "base64":
|
||||||
|
secrets[name] = base64.b64encode(os.urandom(length)).decode()
|
||||||
|
else:
|
||||||
|
secrets[name] = token_hex(length)
|
||||||
|
|
||||||
|
if not secrets:
|
||||||
|
return secrets
|
||||||
|
|
||||||
|
# Store in K8s Secret
|
||||||
|
try:
|
||||||
|
k8s_config.load_kube_config()
|
||||||
|
except Exception:
|
||||||
|
# Fall back to in-cluster config if available
|
||||||
|
try:
|
||||||
|
k8s_config.load_incluster_config()
|
||||||
|
except Exception:
|
||||||
|
print(
|
||||||
|
"Warning: Could not load kube config, secrets will not be stored in K8s"
|
||||||
|
)
|
||||||
|
return secrets
|
||||||
|
|
||||||
|
v1 = client.CoreV1Api()
|
||||||
|
secret_name = f"{deployment_name}-generated-secrets"
|
||||||
|
namespace = "default"
|
||||||
|
|
||||||
|
secret_data = {k: base64.b64encode(v.encode()).decode() for k, v in secrets.items()}
|
||||||
|
k8s_secret = client.V1Secret(
|
||||||
|
metadata=client.V1ObjectMeta(name=secret_name), data=secret_data, type="Opaque"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
v1.create_namespaced_secret(namespace, k8s_secret)
|
||||||
|
num_secrets = len(secrets)
|
||||||
|
print(f"Created K8s Secret '{secret_name}' with {num_secrets} secret(s)")
|
||||||
|
except client.exceptions.ApiException as e:
|
||||||
|
if e.status == 409: # Already exists
|
||||||
|
v1.replace_namespaced_secret(secret_name, namespace, k8s_secret)
|
||||||
|
num_secrets = len(secrets)
|
||||||
|
print(f"Updated K8s Secret '{secret_name}' with {num_secrets} secret(s)")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
return secrets
|
||||||
|
|
||||||
|
|
||||||
|
def _write_config_file(
|
||||||
|
spec_file: Path, config_env_file: Path, deployment_name: Optional[str] = None
|
||||||
|
):
|
||||||
spec_content = get_parsed_deployment_spec(spec_file)
|
spec_content = get_parsed_deployment_spec(spec_file)
|
||||||
# Note: we want to write an empty file even if we have no config variables
|
config_vars = spec_content.get("config", {}) or {}
|
||||||
|
|
||||||
|
# Generate and store secrets in K8s if deployment_name provided and tokens exist
|
||||||
|
if deployment_name and config_vars:
|
||||||
|
has_generate_tokens = any(
|
||||||
|
isinstance(v, str) and GENERATE_TOKEN_PATTERN.search(v)
|
||||||
|
for v in config_vars.values()
|
||||||
|
)
|
||||||
|
if has_generate_tokens:
|
||||||
|
_generate_and_store_secrets(config_vars, deployment_name)
|
||||||
|
|
||||||
|
# Write non-secret config to config.env (exclude $generate:...$ tokens)
|
||||||
with open(config_env_file, "w") as output_file:
|
with open(config_env_file, "w") as output_file:
|
||||||
if "config" in spec_content and spec_content["config"]:
|
if config_vars:
|
||||||
config_vars = spec_content["config"]
|
for variable_name, variable_value in config_vars.items():
|
||||||
if config_vars:
|
# Skip variables with generate tokens - they go to K8s Secret
|
||||||
for variable_name, variable_value in config_vars.items():
|
if isinstance(variable_value, str) and GENERATE_TOKEN_PATTERN.search(
|
||||||
output_file.write(f"{variable_name}={variable_value}\n")
|
variable_value
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
output_file.write(f"{variable_name}={variable_value}\n")
|
||||||
|
|
||||||
|
|
||||||
def _write_kube_config_file(external_path: Path, internal_path: Path):
|
def _write_kube_config_file(external_path: Path, internal_path: Path):
|
||||||
@ -756,7 +842,11 @@ def _write_deployment_files(
|
|||||||
_create_deployment_file(target_dir, stack_source=stack_source)
|
_create_deployment_file(target_dir, stack_source=stack_source)
|
||||||
|
|
||||||
# Copy any config variables from the spec file into an env file suitable for compose
|
# Copy any config variables from the spec file into an env file suitable for compose
|
||||||
_write_config_file(spec_file, target_dir.joinpath(constants.config_file_name))
|
# Use stack_name as deployment_name for K8s secret naming
|
||||||
|
deployment_name = stack_name.replace("_", "-")
|
||||||
|
_write_config_file(
|
||||||
|
spec_file, target_dir.joinpath(constants.config_file_name), deployment_name
|
||||||
|
)
|
||||||
|
|
||||||
# Copy any k8s config file into the target dir
|
# Copy any k8s config file into the target dir
|
||||||
if deployment_type == "k8s":
|
if deployment_type == "k8s":
|
||||||
|
|||||||
@ -453,6 +453,16 @@ class ClusterInfo:
|
|||||||
if "command" in service_info:
|
if "command" in service_info:
|
||||||
cmd = service_info["command"]
|
cmd = service_info["command"]
|
||||||
container_args = cmd if isinstance(cmd, list) else cmd.split()
|
container_args = cmd if isinstance(cmd, list) else cmd.split()
|
||||||
|
# Add env_from to pull secrets from K8s Secret
|
||||||
|
secret_name = f"{self.app_name}-generated-secrets"
|
||||||
|
env_from = [
|
||||||
|
client.V1EnvFromSource(
|
||||||
|
secret_ref=client.V1SecretEnvSource(
|
||||||
|
name=secret_name,
|
||||||
|
optional=True, # Don't fail if no secrets
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
container = client.V1Container(
|
container = client.V1Container(
|
||||||
name=container_name,
|
name=container_name,
|
||||||
image=image_to_use,
|
image=image_to_use,
|
||||||
@ -460,6 +470,7 @@ class ClusterInfo:
|
|||||||
command=container_command,
|
command=container_command,
|
||||||
args=container_args,
|
args=container_args,
|
||||||
env=envs,
|
env=envs,
|
||||||
|
env_from=env_from,
|
||||||
ports=container_ports if container_ports else None,
|
ports=container_ports if container_ports else None,
|
||||||
volume_mounts=volume_mounts,
|
volume_mounts=volume_mounts,
|
||||||
security_context=client.V1SecurityContext(
|
security_context=client.V1SecurityContext(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user