Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ Pipeline logs are streamed via WebSocket (`PipelineLogWebSocketHandler`). A `@Sc
### Application Deployment
Applications deploy as **StatefulSets** (not Deployments) with `enableServiceLinks(false)` to prevent K8s env var pollution. Ingress uses **Traefik IngressRoute CRDs** (not standard Kubernetes Ingress) — the code checks for CRD existence and skips gracefully if absent. ConfigMap named after the application is injected via `envFrom: configMapRef`.

**Config item metadata (no DB table)**: Env/file config items carry UI-only metadata — `mountPath` (file mounts), plus `group` and `comment` for organizing and documenting keys in the editor. Rather than a new table, `KubernetesConfigMapGateway` serializes this as JSON in resource annotations on each `{app}` / `{app}.files` ConfigMap/Secret: `oops.mounts` (`key → mountPath`) and `oops.config-meta` (`key → {group, comment}`). Each annotation only describes the keys in its own resource's `data`, so items moving between env/secret/mounted carry their metadata along on the next full-rewrite save. The metadata never enters the container. Frontend `application-config-info.tsx` renders collapsible per-group sections with key/comment search; `env-import-dialog.tsx` round-trips groups as `## group: xxx` markers and comments as `#` lines in the dotenv import/export format.

Deployment triggering logic lives in `DeploymentService` (not `PipelineService`). The `DeploymentController` at `/api/namespaces/{namespace}/applications/{name}/deployments` handles deploy triggers and source uploads.

**Deploy processor chain**: `ArtifactDeployTask` orchestrates deployment via a sequence of `DeployProcessor`s sharing a `DeployContext`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ public class ConfigMapItem {
*/
private String mountPath;

/**
* Optional display group used to organize items in the UI. Stored as metadata in the
* {@code oops.config-meta} annotation, never in the resource data.
*/
private String group;

/**
* Optional free-text note describing the item. Stored as metadata in the
* {@code oops.config-meta} annotation, never in the resource data.
*/
private String comment;

/**
* Optional display position. Persisted in the {@code oops.config-meta} annotation so a manual ordering
* survives the unordered ConfigMap/Secret data map.
*/
private Integer order;

public String getName() {
return toResourceName(key);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,16 @@ public class UpdateConfigMapCommand {
* container; when blank the item is exposed as an environment variable only.
*/
private String mountPath;

/**
* Optional display group used to organize items in the UI. Stored as metadata in the
* {@code oops.config-meta} annotation, never in the resource data.
*/
private String group;

/**
* Optional free-text note describing the item. Stored as metadata in the
* {@code oops.config-meta} annotation, never in the resource data.
*/
private String comment;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.github.wellch4n.oops.infrastructure.kubernetes;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.wellch4n.oops.application.port.ConfigMapGateway;
Expand All @@ -19,6 +20,7 @@
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
Expand Down Expand Up @@ -47,6 +49,13 @@ public class KubernetesConfigMapGateway implements ConfigMapGateway {
*/
public static final String FILES_RESOURCE_SUFFIX = ".files";

/**
* Annotation on the application ConfigMaps/Secrets holding the {@code key -> {group, comment}} map for
* the items each resource carries. Pure UI metadata: it organizes and documents items in the config
* editor and never reaches the container.
*/
public static final String CONFIG_META_ANNOTATION = "oops.config-meta";

private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

private final KubernetesClientPool clientPool;
Expand All @@ -63,28 +72,39 @@ public List<ConfigMapItem> getConfigMaps(Environment environment, String namespa

ConfigMap envConfigMap = client.configMaps().inNamespace(namespace).withName(applicationName).get();
if (envConfigMap != null && envConfigMap.getData() != null) {
envConfigMap.getData().forEach((key, value) -> items.add(buildItem(key, value, false, null)));
Map<String, ConfigMeta> metas = readConfigMetas(envConfigMap.getMetadata());
envConfigMap.getData().forEach((key, value) ->
items.add(buildItem(key, value, false, null, metas.get(key))));
}

Secret envSecret = client.secrets().inNamespace(namespace).withName(applicationName).get();
if (envSecret != null && envSecret.getData() != null) {
envSecret.getData().forEach((key, value) -> items.add(buildItem(key, decode(value), true, null)));
Map<String, ConfigMeta> metas = readConfigMetas(envSecret.getMetadata());
envSecret.getData().forEach((key, value) ->
items.add(buildItem(key, decode(value), true, null, metas.get(key))));
}

ConfigMap fileConfigMap = client.configMaps().inNamespace(namespace).withName(filesName).get();
if (fileConfigMap != null && fileConfigMap.getData() != null) {
Map<String, String> mounts = readMounts(fileConfigMap.getMetadata());
Map<String, ConfigMeta> metas = readConfigMetas(fileConfigMap.getMetadata());
fileConfigMap.getData().forEach((key, value) ->
items.add(buildItem(key, value, false, mounts.get(key))));
items.add(buildItem(key, value, false, mounts.get(key), metas.get(key))));
}

Secret fileSecret = client.secrets().inNamespace(namespace).withName(filesName).get();
if (fileSecret != null && fileSecret.getData() != null) {
Map<String, String> mounts = readMounts(fileSecret.getMetadata());
Map<String, ConfigMeta> metas = readConfigMetas(fileSecret.getMetadata());
fileSecret.getData().forEach((key, value) ->
items.add(buildItem(key, decode(value), true, mounts.get(key))));
items.add(buildItem(key, decode(value), true, mounts.get(key), metas.get(key))));
}

// Restore the editor's manual ordering from the order meta. Items without an order (legacy data,
// freshly added keys) sort last, keyed by name for a stable, predictable fallback.
items.sort(Comparator
.comparing((ConfigMapItem item) -> item.getOrder() == null ? Integer.MAX_VALUE : item.getOrder())
.thenComparing(ConfigMapItem::getKey));
return items;
}

Expand All @@ -108,27 +128,39 @@ public void updateConfigMap(Environment environment,
// resources (injected wholesale via envFrom); mounted items go into {app}.files (projected as files)
// so they are never exposed as environment variables.
Map<String, String> envConfig = new HashMap<>();
Map<String, ConfigMeta> envConfigMetas = new LinkedHashMap<>();
Map<String, String> envSecret = new HashMap<>();
Map<String, ConfigMeta> envSecretMetas = new LinkedHashMap<>();
Map<String, String> fileConfig = new LinkedHashMap<>();
Map<String, String> fileConfigMounts = new LinkedHashMap<>();
Map<String, ConfigMeta> fileConfigMetas = new LinkedHashMap<>();
Map<String, String> fileSecret = new LinkedHashMap<>();
Map<String, String> fileSecretMounts = new LinkedHashMap<>();
Map<String, ConfigMeta> fileSecretMetas = new LinkedHashMap<>();

// The incoming list is already in display order; persist each item's index as its order meta so the
// arrangement (including manual drag reordering) survives the unordered ConfigMap data map.
int position = 0;
for (UpdateConfigMapCommand command : configMaps) {
boolean mounted = StringUtils.isNotBlank(command.getMountPath());
ConfigMeta meta = ConfigMeta.of(command.getGroup(), command.getComment(), position++);
if (command.isSecret()) {
if (mounted) {
fileSecret.put(command.getKey(), command.getValue());
fileSecretMounts.put(command.getKey(), command.getMountPath().trim());
putMeta(fileSecretMetas, command.getKey(), meta);
} else {
envSecret.put(command.getKey(), command.getValue());
putMeta(envSecretMetas, command.getKey(), meta);
}
} else {
if (mounted) {
fileConfig.put(command.getKey(), command.getValue());
fileConfigMounts.put(command.getKey(), command.getMountPath().trim());
putMeta(fileConfigMetas, command.getKey(), meta);
} else {
envConfig.put(command.getKey(), command.getValue());
putMeta(envConfigMetas, command.getKey(), meta);
}
}
}
Expand All @@ -137,51 +169,60 @@ public void updateConfigMap(Environment environment,

// The env ConfigMap is always present: envFrom references it and it predates this feature. The Secret
// and the two .files resources are created on demand and removed once empty so stale keys don't linger.
applyConfigMap(client, namespace, applicationName, envConfig, Map.of(), ownerReference);
applyOrDeleteSecret(client, namespace, applicationName, envSecret, Map.of(), ownerReference);
applyOrDeleteConfigMap(client, namespace, filesName, fileConfig, fileConfigMounts, ownerReference);
applyOrDeleteSecret(client, namespace, filesName, fileSecret, fileSecretMounts, ownerReference);
applyConfigMap(client, namespace, applicationName, envConfig, Map.of(), envConfigMetas, ownerReference);
applyOrDeleteSecret(client, namespace, applicationName, envSecret, Map.of(), envSecretMetas, ownerReference);
applyOrDeleteConfigMap(client, namespace, filesName, fileConfig, fileConfigMounts, fileConfigMetas, ownerReference);
applyOrDeleteSecret(client, namespace, filesName, fileSecret, fileSecretMounts, fileSecretMetas, ownerReference);
}

private void putMeta(Map<String, ConfigMeta> metas, String key, ConfigMeta meta) {
if (meta != null) {
metas.put(key, meta);
}
}

private void applyOrDeleteConfigMap(KubernetesClient client,
String namespace,
String name,
Map<String, String> data,
Map<String, String> mounts,
Map<String, ConfigMeta> metas,
OwnerReference ownerReference) {
if (data.isEmpty()) {
client.configMaps().inNamespace(namespace).withName(name).delete();
return;
}
applyConfigMap(client, namespace, name, data, mounts, ownerReference);
applyConfigMap(client, namespace, name, data, mounts, metas, ownerReference);
}

private void applyOrDeleteSecret(KubernetesClient client,
String namespace,
String name,
Map<String, String> data,
Map<String, String> mounts,
Map<String, ConfigMeta> metas,
OwnerReference ownerReference) {
if (data.isEmpty()) {
client.secrets().inNamespace(namespace).withName(name).delete();
return;
}
applySecret(client, namespace, name, data, mounts, ownerReference);
applySecret(client, namespace, name, data, mounts, metas, ownerReference);
}

private void applyConfigMap(KubernetesClient client,
String namespace,
String name,
Map<String, String> data,
Map<String, String> mounts,
Map<String, ConfigMeta> metas,
OwnerReference ownerReference) {
ConfigMapBuilder builder = new ConfigMapBuilder()
.withApiVersion("v1")
.withKind("ConfigMap")
.withNewMetadata()
.withName(name)
.withNamespace(namespace)
.withAnnotations(mountAnnotations(mounts))
.withAnnotations(annotations(mounts, metas))
.endMetadata()
.withData(data);
if (ownerReference != null) {
Expand All @@ -195,6 +236,7 @@ private void applySecret(KubernetesClient client,
String name,
Map<String, String> data,
Map<String, String> mounts,
Map<String, ConfigMeta> metas,
OwnerReference ownerReference) {
SecretBuilder builder = new SecretBuilder()
.withApiVersion("v1")
Expand All @@ -203,7 +245,7 @@ private void applySecret(KubernetesClient client,
.withNewMetadata()
.withName(name)
.withNamespace(namespace)
.withAnnotations(mountAnnotations(mounts))
.withAnnotations(annotations(mounts, metas))
.endMetadata()
.withStringData(data);
if (ownerReference != null) {
Expand All @@ -212,12 +254,17 @@ private void applySecret(KubernetesClient client,
client.secrets().inNamespace(namespace).resource(builder.build()).serverSideApply();
}

private ConfigMapItem buildItem(String key, String value, boolean secret, String mountPath) {
private ConfigMapItem buildItem(String key, String value, boolean secret, String mountPath, ConfigMeta meta) {
ConfigMapItem item = new ConfigMapItem();
item.setKey(key);
item.setValue(value);
item.setSecret(secret);
item.setMountPath(mountPath);
if (meta != null) {
item.setGroup(meta.group());
item.setComment(meta.comment());
item.setOrder(meta.order());
}
return item;
}

Expand All @@ -235,15 +282,21 @@ private OwnerReference ownerReferenceOf(String applicationName, StatefulSet stat
.build();
}

static Map<String, String> mountAnnotations(Map<String, String> mounts) {
static Map<String, String> annotations(Map<String, String> mounts, Map<String, ConfigMeta> metas) {
Map<String, String> annotations = new HashMap<>();
if (mounts.isEmpty()) {
return annotations;
if (!mounts.isEmpty()) {
try {
annotations.put(MOUNT_ANNOTATION, OBJECT_MAPPER.writeValueAsString(mounts));
} catch (Exception exception) {
log.warn("Failed to serialize mount annotation: {}", exception.getMessage());
}
}
try {
annotations.put(MOUNT_ANNOTATION, OBJECT_MAPPER.writeValueAsString(mounts));
} catch (Exception exception) {
log.warn("Failed to serialize mount annotation: {}", exception.getMessage());
if (!metas.isEmpty()) {
try {
annotations.put(CONFIG_META_ANNOTATION, OBJECT_MAPPER.writeValueAsString(metas));
} catch (Exception exception) {
log.warn("Failed to serialize config meta annotation: {}", exception.getMessage());
}
}
return annotations;
}
Expand All @@ -269,6 +322,41 @@ public static Map<String, String> readMounts(ObjectMeta metadata) {
}
}

/**
* Per-key UI metadata serialized as {@code key -> {group, comment, order}} in the {@link
* #CONFIG_META_ANNOTATION} annotation. Blank strings are normalized to {@code null}; {@code order} is the
* item's display position, letting the editor persist a manual ordering that the ConfigMap {@code data}
* map (unordered) cannot. A key with no group, no comment, and no order is not stored at all.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ConfigMeta(String group, String comment, Integer order) {

static ConfigMeta of(String group, String comment, Integer order) {
String normalizedGroup = StringUtils.trimToNull(group);
String normalizedComment = StringUtils.trimToNull(comment);
if (normalizedGroup == null && normalizedComment == null && order == null) {
return null;
}
return new ConfigMeta(normalizedGroup, normalizedComment, order);
}
}

static Map<String, ConfigMeta> readConfigMetas(ObjectMeta metadata) {
if (metadata == null || metadata.getAnnotations() == null) {
return Map.of();
}
String raw = metadata.getAnnotations().get(CONFIG_META_ANNOTATION);
if (StringUtils.isBlank(raw)) {
return Map.of();
}
try {
return OBJECT_MAPPER.readValue(raw, new TypeReference<Map<String, ConfigMeta>>() {});
} catch (Exception exception) {
log.warn("Failed to parse config meta annotation: {}", exception.getMessage());
return Map.of();
}
}

private String decode(String base64Value) {
if (base64Value == null) {
return "";
Expand Down
Loading