diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 832a7d9..c6ad42a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,6 @@ name: Build Docker Image on: + workflow_dispatch: pull_request: types: - opened diff --git a/README.md b/README.md index b8dca5d..f9ec938 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ Dirigent simplifies **GitOps for Docker Compose deployments** by automating the - **REST API**: Integrate with existing tools (CI/CD, monitoring). - **Gotify notifications**: Get alerts when deployments fail. +### 🔒 Encrypted Secrets +- You can store encrypted Secrets that are injected as Environment Variable on execution + Ideal for Self-hosters managing multiple services (e.g., Nextcloud, Gitea, Vaultwarden). ## Setup @@ -66,6 +69,7 @@ services: restart: unless-stopped environment: - DIRIGENT_DEPLOYMENTS_GIT_URL= # required + - DIRIGENT_SECRETS_ENCRYPTION_KEY= # Has to be 16 Chars. Can be generated with `openssl rand -base64 12` - DIRIGENT_COMPOSE_COMMAND= # optional - DIRIGENT_GIT_AUTHTOKEN= # optional - DIRIGENT_START_ALL_ON_STARTUP= # optional @@ -89,6 +93,7 @@ services: docker run -d \ --name=dirigent \ -e DIRIGENT_DEPLOYMENTS_GIT_URL= \ + -e DIRIGENT_SECRETS_ENCRYPTION_KEY= \ # Has to be 16 Chars. Can be generated with `openssl rand -base64 12` #optional -e DIRIGENT_COMPOSE_COMMAND= \ #optional @@ -105,6 +110,7 @@ docker run -d \ -e DIRIGENT_GOTIFY_TOKEN= \ #optional but recommended -e DIRIGENT_INSTANCENAME= \ + -v /path/to/config:/app/config \ -v /path/to/deployments:/app/deployments \ -v /path/to/data:/app/data \ @@ -117,6 +123,7 @@ docker run -d \ | Variable | Description | Default | |---------------------------------------|-------------------------------------------------------------------------------------------------------|------------------| | DIRIGENT_DEPLOYMENTS_GIT_URL | URL to your deployments git repository | | +| DIRIGENT_SECRETS_ENCRYPTION_KEY | Encryption Key to save encrypted secrets. Has to be 16 Chars. Can be generated with `openssl rand -base64 12` | | | DIRIGENT_COMPOSE_COMMAND | Command to run your docker-compose files | `docker compose` | | DIRIGENT_GIT_AUTHTOKEN | Auth token with access to your repos | | | DIRIGENT_START_ALL_ON_STARTUP | Start all deployments on startup | `true` | @@ -190,9 +197,7 @@ Store all your repositories for one host in one gitea organization. This way you | Parameter | Description | |-----------------|------------------------------------------------------| -| `force=true` | forces Recreation and Run of targeted deployment(s) | -| `forceRun=true` | only forces run of targeted deployment(s) | -| `forceRecreate` | only forces recreation of the targeted deployment(s) | +| `forceRecreate=true` | forces recreation of the targeted deployment(s) | ##### All Deployments: @@ -212,6 +217,31 @@ Store all your repositories for one host in one gitea organization. This way you `GET` to `/api/v1/deployment-states` +### Secrets + +#### Model + +```json +{ + "key": "string", + "value": "string", + "environmentVariable": "string", + "deployments": ["string"] +} +``` + +#### Get Secrets + +`GET` to `/api/v1/secrets` + +#### Save Secret + +`PUT` to `/api/v1/secrets/{key}` + +#### Delete Secret + +`DELETE` to `/api/v1/secrets/{iey}` + ## Develop ### Setup for local Tests diff --git a/backend/pom.xml b/backend/pom.xml index 9ec23f0..cce48a5 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -10,7 +10,7 @@ org.davidbohl dirigent - 0.5.0-SNAPSHOT + 1.0.0-SNAPSHOT dirigent Helper for Docker Composes @@ -63,9 +63,12 @@ 4.5.14 - com.h2database - h2 - runtime + org.xerial + sqlite-jdbc + + + org.hibernate.orm + hibernate-community-dialects org.projectlombok diff --git a/backend/src/main/java/org/davidbohl/dirigent/deployments/api/DeploymentsController.java b/backend/src/main/java/org/davidbohl/dirigent/deployments/api/DeploymentsController.java index d9504bb..036998d 100644 --- a/backend/src/main/java/org/davidbohl/dirigent/deployments/api/DeploymentsController.java +++ b/backend/src/main/java/org/davidbohl/dirigent/deployments/api/DeploymentsController.java @@ -33,8 +33,8 @@ public class DeploymentsController { } @PostMapping("/{name}/start") - public void startDeployment(@PathVariable String name, @RequestParam(required = false) boolean force) { - applicationEventPublisher.publishEvent(new NamedDeploymentStartRequestedEvent(this, name, force)); + public void startDeployment(@PathVariable String name, @RequestParam(required = false) boolean forceRecreate) { + applicationEventPublisher.publishEvent(new NamedDeploymentStartRequestedEvent(this, name, forceRecreate)); } @PostMapping("/{name}/stop") @@ -43,12 +43,8 @@ public class DeploymentsController { } @PostMapping("/all/start") - public void startAllDeployments(@RequestParam(required = false) boolean force, - @RequestParam(required = false) boolean forceRun, - @RequestParam(required = false) boolean forceRecreate) { - applicationEventPublisher.publishEvent(new AllDeploymentsStartRequestedEvent(this, - force || forceRun, - force || forceRecreate)); + public void startAllDeployments(@RequestParam(required = false) boolean forceRecreate) { + applicationEventPublisher.publishEvent(new AllDeploymentsStartRequestedEvent(this, forceRecreate)); } @GetMapping() diff --git a/backend/src/main/java/org/davidbohl/dirigent/deployments/events/AllDeploymentsStartRequestedEvent.java b/backend/src/main/java/org/davidbohl/dirigent/deployments/events/AllDeploymentsStartRequestedEvent.java index bd54c6d..73f54f6 100644 --- a/backend/src/main/java/org/davidbohl/dirigent/deployments/events/AllDeploymentsStartRequestedEvent.java +++ b/backend/src/main/java/org/davidbohl/dirigent/deployments/events/AllDeploymentsStartRequestedEvent.java @@ -6,12 +6,10 @@ import org.springframework.context.ApplicationEvent; @Getter public class AllDeploymentsStartRequestedEvent extends ApplicationEvent { - private final boolean forceRun; private final boolean forceRecreate; - public AllDeploymentsStartRequestedEvent(Object source, boolean forceRun, boolean forceRecreate) { + public AllDeploymentsStartRequestedEvent(Object source, boolean forceRecreate) { super(source); - this.forceRun = forceRun; this.forceRecreate = forceRecreate; } } diff --git a/backend/src/main/java/org/davidbohl/dirigent/deployments/events/MultipleNamedDeploymentsStartRequestedEvent.java b/backend/src/main/java/org/davidbohl/dirigent/deployments/events/MultipleNamedDeploymentsStartRequestedEvent.java new file mode 100644 index 0000000..9e8a122 --- /dev/null +++ b/backend/src/main/java/org/davidbohl/dirigent/deployments/events/MultipleNamedDeploymentsStartRequestedEvent.java @@ -0,0 +1,18 @@ +package org.davidbohl.dirigent.deployments.events; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +import java.util.List; + +@Getter +public class MultipleNamedDeploymentsStartRequestedEvent extends ApplicationEvent { + private final List names; + private final boolean forceRecreate; + + public MultipleNamedDeploymentsStartRequestedEvent(Object source, List names, boolean forceRecreate) { + super(source); + this.names = names; + this.forceRecreate = forceRecreate; + } +} diff --git a/backend/src/main/java/org/davidbohl/dirigent/deployments/events/NamedDeploymentStartRequestedEvent.java b/backend/src/main/java/org/davidbohl/dirigent/deployments/events/NamedDeploymentStartRequestedEvent.java index 8fc79fc..4942271 100644 --- a/backend/src/main/java/org/davidbohl/dirigent/deployments/events/NamedDeploymentStartRequestedEvent.java +++ b/backend/src/main/java/org/davidbohl/dirigent/deployments/events/NamedDeploymentStartRequestedEvent.java @@ -7,12 +7,12 @@ import org.springframework.context.ApplicationEvent; public class NamedDeploymentStartRequestedEvent extends ApplicationEvent { private final String name; - private final boolean forced; + private final boolean forceRecreate; - public NamedDeploymentStartRequestedEvent(Object source, String name, boolean forced) { + public NamedDeploymentStartRequestedEvent(Object source, String name, boolean forceRecreate) { super(source); this.name = name; - this.forced = forced; + this.forceRecreate = forceRecreate; } } diff --git a/backend/src/main/java/org/davidbohl/dirigent/deployments/management/DeploymentsService.java b/backend/src/main/java/org/davidbohl/dirigent/deployments/management/DeploymentsService.java index b608b3f..b6ffabd 100644 --- a/backend/src/main/java/org/davidbohl/dirigent/deployments/management/DeploymentsService.java +++ b/backend/src/main/java/org/davidbohl/dirigent/deployments/management/DeploymentsService.java @@ -6,6 +6,7 @@ import org.davidbohl.dirigent.deployments.models.Deployment; import org.davidbohl.dirigent.deployments.models.DeploynentConfiguration; import org.davidbohl.dirigent.deployments.state.DeploymentState; import org.davidbohl.dirigent.deployments.state.DeploymentStatePersistingService; +import org.davidbohl.dirigent.sercrets.SecretService; import org.davidbohl.dirigent.utility.GitService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,17 +33,22 @@ public class DeploymentsService { private final Logger logger = LoggerFactory.getLogger(DeploymentsService.class); private final ApplicationEventPublisher applicationEventPublisher; private final DeploymentStatePersistingService deploymentStatePersistingService; + private final SecretService secretService; @Value("${dirigent.compose.command}") private String composeCommand; public DeploymentsService( DeploymentsConfigurationProvider deploymentsConfigurationProvider, - GitService gitService, ApplicationEventPublisher applicationEventPublisher, DeploymentStatePersistingService deploymentStatePersistingService) { + GitService gitService, + ApplicationEventPublisher applicationEventPublisher, + DeploymentStatePersistingService deploymentStatePersistingService, + SecretService secretService) { this.deploymentsConfigurationProvider = deploymentsConfigurationProvider; this.gitService = gitService; this.applicationEventPublisher = applicationEventPublisher; this.deploymentStatePersistingService = deploymentStatePersistingService; + this.secretService = secretService; } @EventListener(AllDeploymentsStartRequestedEvent.class) @@ -51,7 +57,7 @@ public class DeploymentsService { makeDeploymentsDir(); DeploynentConfiguration deploymentsConfiguration = tryGetConfiguration(); - deployListOfDeployments(deploymentsConfiguration.deployments(), event.isForceRun(), event.isForceRecreate()); + deployListOfDeployments(deploymentsConfiguration.deployments(), event.isForceRecreate()); stopNotConfiguredDeployments(deploymentsConfiguration.deployments()); } @@ -61,6 +67,29 @@ public class DeploymentsService { throw new DeploymentsDirCouldNotBeCreatedException(); } + @EventListener(MultipleNamedDeploymentsStartRequestedEvent.class) + public void onMultipleNamedDeploymentsStartRequested(MultipleNamedDeploymentsStartRequestedEvent event) { + makeDeploymentsDir(); + DeploynentConfiguration deploynentConfiguration = tryGetConfiguration(); + + + List deploymentStates = deploymentStatePersistingService.getDeploymentStates(); + List relevantDeploymentStates = deploymentStates.stream() + .filter(ds -> event.getNames().stream().anyMatch(n -> n.equals(ds.getName()) && + (ds.getState() != DeploymentState.State.STOPPED && ds.getState() != DeploymentState.State.REMOVED))) + .toList(); + + List deployments = deploynentConfiguration.deployments().stream().filter( + d -> relevantDeploymentStates.stream().anyMatch(ds -> Objects.equals(ds.getName(), d.name())) + ).toList(); + + for (Deployment deployment : deployments) { + deploy(deployment, event.isForceRecreate()); + } + + + } + @EventListener(NamedDeploymentStartRequestedEvent.class) public void onNamedDeploymentStartRequested(NamedDeploymentStartRequestedEvent event) { makeDeploymentsDir(); @@ -71,7 +100,7 @@ public class DeploymentsService { if (first.isEmpty()) throw new DeploymentNameNotFoundException(event.getName()); - deploy(first.get(), event.isForced(), event.isForced()); + deploy(first.get(), event.isForceRecreate()); } @EventListener(SourceDeploymentStartRequestedEvent.class) @@ -84,7 +113,7 @@ public class DeploymentsService { .filter(d -> Objects.equals(d.source(), event.getDeploymentSource())) .collect(Collectors.toList()); - deployListOfDeployments(deployments, true, true); + deployListOfDeployments(deployments, true); } @EventListener(NamedDeploymentStopRequestedEvent.class) @@ -115,26 +144,34 @@ public class DeploymentsService { .filter(d -> !stoppedDeployments.contains(d.name())) .toList(); - deployListOfDeployments(deployments, true, false); + deployListOfDeployments(deployments, false); } - private void deploy(Deployment deployment, boolean forceRun, boolean forceRecreate) { + private void deploy(Deployment deployment, boolean forceRecreate) { logger.info("Deploying {}", deployment.name()); File deploymentDir = new File("deployments/" + deployment.name()); try { + boolean updated = gitService.updateRepo(deployment.source(), deploymentDir.getAbsolutePath()); + Optional optionalState = deploymentStatePersistingService.getDeploymentStates().stream() + .filter(state -> state.getName().equals(deployment.name())) + .findFirst(); + + boolean deployWouldChangeState = optionalState.isEmpty() || optionalState.get().getState() != DeploymentState.State.RUNNING; + + + if (!updated && !forceRecreate && !deployWouldChangeState) { + applicationEventPublisher.publishEvent(new DeploymentStateEvent(this, deployment.name(), DeploymentState.State.RUNNING, "Deployment '%s' successfully started".formatted(deployment.name()))); + logger.info("No update, forced recreation or changed states in deployment. Skipping {}", deployment.name()); + return; + } if (updated) { applicationEventPublisher.publishEvent(new DeploymentStateEvent(this, deployment.name(), DeploymentState.State.UPDATED, "Deployment '%s' updated".formatted(deployment.name()))); } - - if (!updated && !forceRun) { - applicationEventPublisher.publishEvent(new DeploymentStateEvent(this, deployment.name(), DeploymentState.State.RUNNING, "Deployment '%s' successfully started".formatted(deployment.name()))); - logger.info("No changes in deployment. Skipping {}", deployment.name()); - return; - } + applicationEventPublisher.publishEvent(new DeploymentStateEvent(this, deployment.name(), DeploymentState.State.STARTING, "Starting Deployment '%s'".formatted(deployment.name()))); List commandArgs = new java.util.ArrayList<>(Arrays.stream(composeCommand.split(" ")).toList()); commandArgs.add("up"); @@ -146,8 +183,12 @@ public class DeploymentsService { } logger.info("Upping Compose for {}", deployment.name()); - Process process = new ProcessBuilder(commandArgs) - .directory(deploymentDir) + ProcessBuilder builder = new ProcessBuilder(commandArgs) + .directory(deploymentDir); + + builder.environment().putAll(secretService.getAllSecretsAsEnvironmentVariableMapByDeployment(deployment.name())); + + Process process = builder .start(); BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream())); @@ -194,6 +235,18 @@ public class DeploymentsService { private void stopDeployment(String deploymentName) throws InterruptedException, IOException { logger.info("Stopping deployment {}", deploymentName); + + + Optional optionalState = deploymentStatePersistingService.getDeploymentStates().stream() + .filter(state -> state.getName().equals(deploymentName)) + .findFirst(); + + boolean stopWouldChangeState = optionalState.isEmpty() || optionalState.get().getState() != DeploymentState.State.STOPPED; + + if (stopWouldChangeState) { + applicationEventPublisher.publishEvent(new DeploymentStateEvent(this, deploymentName, DeploymentState.State.STOPPING, "Stopping deployment '%s'".formatted(deploymentName))); + } + List commandArgs = new ArrayList<>(Arrays.stream(composeCommand.split(" ")).toList()); commandArgs.add("down"); new ProcessBuilder(commandArgs) @@ -203,7 +256,7 @@ public class DeploymentsService { applicationEventPublisher.publishEvent(new DeploymentStateEvent(this, deploymentName, DeploymentState.State.STOPPED, "Deployment '%s' stopped".formatted(deploymentName))); } - void deleteDirectory(File directoryToBeDeleted) { + private void deleteDirectory(File directoryToBeDeleted) { File[] allContents = directoryToBeDeleted.listFiles(); if (allContents != null) { for (File file : allContents) { @@ -216,7 +269,7 @@ public class DeploymentsService { throw new RuntimeException("Could not delete directory " + directoryToBeDeleted); } - private void deployListOfDeployments(List deployments, boolean forceRun, boolean forceRecreate) { + private void deployListOfDeployments(List deployments, boolean forceRecreate) { makeDeploymentsDir(); @@ -233,7 +286,7 @@ public class DeploymentsService { ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor(); for (Deployment deployment : deploymentsOrderUnit) { - executorService.submit(() -> deploy(deployment, forceRun, forceRecreate)); + executorService.submit(() -> deploy(deployment, forceRecreate)); } executorService.shutdown(); diff --git a/backend/src/main/java/org/davidbohl/dirigent/deployments/state/DeploymentState.java b/backend/src/main/java/org/davidbohl/dirigent/deployments/state/DeploymentState.java index b92a4fc..cea5308 100644 --- a/backend/src/main/java/org/davidbohl/dirigent/deployments/state/DeploymentState.java +++ b/backend/src/main/java/org/davidbohl/dirigent/deployments/state/DeploymentState.java @@ -12,6 +12,7 @@ import lombok.Setter; @Setter @Entity public class DeploymentState { + @Id private String name; diff --git a/backend/src/main/java/org/davidbohl/dirigent/deployments/state/DeploymentStatePersistingService.java b/backend/src/main/java/org/davidbohl/dirigent/deployments/state/DeploymentStatePersistingService.java index 8115226..5c03aa3 100644 --- a/backend/src/main/java/org/davidbohl/dirigent/deployments/state/DeploymentStatePersistingService.java +++ b/backend/src/main/java/org/davidbohl/dirigent/deployments/state/DeploymentStatePersistingService.java @@ -6,9 +6,9 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; +import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.stream.StreamSupport; @Service public class DeploymentStatePersistingService { @@ -36,8 +36,15 @@ public class DeploymentStatePersistingService { } public List getDeploymentStates() { - return StreamSupport.stream(deploymentStateRepository.findAll().spliterator(), false) - .toList(); + Iterable all = deploymentStateRepository.findAll(); + + List result = new ArrayList<>(); + + for (DeploymentState deploymentState : all) { + result.add(deploymentState); + } + + return result; } } diff --git a/backend/src/main/java/org/davidbohl/dirigent/sercrets/Secret.java b/backend/src/main/java/org/davidbohl/dirigent/sercrets/Secret.java new file mode 100644 index 0000000..05c065d --- /dev/null +++ b/backend/src/main/java/org/davidbohl/dirigent/sercrets/Secret.java @@ -0,0 +1,30 @@ +package org.davidbohl.dirigent.sercrets; + +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Entity +public class Secret { + @Id + private String key; + + private String environmentVariable; + + private String encryptedValue; + + @ElementCollection(fetch = FetchType.EAGER) + private List deployments; + +} \ No newline at end of file diff --git a/backend/src/main/java/org/davidbohl/dirigent/sercrets/SecretController.java b/backend/src/main/java/org/davidbohl/dirigent/sercrets/SecretController.java new file mode 100644 index 0000000..fc18b85 --- /dev/null +++ b/backend/src/main/java/org/davidbohl/dirigent/sercrets/SecretController.java @@ -0,0 +1,44 @@ +package org.davidbohl.dirigent.sercrets; + +import java.util.List; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import lombok.extern.slf4j.Slf4j; + +@RestController() +@RequestMapping(path = "/api/v1/secrets") +@Slf4j +public class SecretController { + + private final SecretService secretService; + + + public SecretController(SecretService secretService) { + this.secretService = secretService; + } + + @PutMapping("{key}") + public void saveSecret(@RequestBody SecretDto secret, @PathVariable String key, @RequestParam(required = false, defaultValue = "false") boolean restartDeployments) { + this.secretService.saveSecret(key, secret.environmentVariable(), secret.value(), secret.deployments(), restartDeployments); + } + + @DeleteMapping("{key}") + public void deleteSecret(@PathVariable String key, @RequestParam(required = false, defaultValue = "false") boolean restartDeployments) { + this.secretService.deleteSecret(key, restartDeployments); + } + + @GetMapping + public List getSecrets() { + return this.secretService.getAllSecretsWithoutValues(); + } + + +} \ No newline at end of file diff --git a/backend/src/main/java/org/davidbohl/dirigent/sercrets/SecretDto.java b/backend/src/main/java/org/davidbohl/dirigent/sercrets/SecretDto.java new file mode 100644 index 0000000..683a181 --- /dev/null +++ b/backend/src/main/java/org/davidbohl/dirigent/sercrets/SecretDto.java @@ -0,0 +1,8 @@ +package org.davidbohl.dirigent.sercrets; + +import java.util.List; + +public record SecretDto(String key, String environmentVariable, String value, List deployments) { + +} + diff --git a/backend/src/main/java/org/davidbohl/dirigent/sercrets/SecretRepository.java b/backend/src/main/java/org/davidbohl/dirigent/sercrets/SecretRepository.java new file mode 100644 index 0000000..d7316ba --- /dev/null +++ b/backend/src/main/java/org/davidbohl/dirigent/sercrets/SecretRepository.java @@ -0,0 +1,12 @@ +package org.davidbohl.dirigent.sercrets; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SecretRepository extends JpaRepository { + + List findAllByDeploymentsContaining(String deployment); + List findAllByEnvironmentVariableAndDeploymentsContaining(String environmentVariable, String deployment); + +} \ No newline at end of file diff --git a/backend/src/main/java/org/davidbohl/dirigent/sercrets/SecretService.java b/backend/src/main/java/org/davidbohl/dirigent/sercrets/SecretService.java new file mode 100644 index 0000000..a519866 --- /dev/null +++ b/backend/src/main/java/org/davidbohl/dirigent/sercrets/SecretService.java @@ -0,0 +1,121 @@ +package org.davidbohl.dirigent.sercrets; + +import lombok.extern.slf4j.Slf4j; +import org.davidbohl.dirigent.deployments.events.MultipleNamedDeploymentsStartRequestedEvent; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.util.*; +import java.util.stream.Stream; + + +@Service +@Slf4j +public class SecretService { + + private static final String ALGORITHM = "AES"; + + private final String encryptionKey; + private final SecretRepository secretRepository; + private final ApplicationEventPublisher applicationEventPublisher; + + public SecretService(@Value("${dirigent.secrets.encryption.key}") String encryptionKey, SecretRepository secretRepository, ApplicationEventPublisher applicationEventPublisher) { + this.applicationEventPublisher = applicationEventPublisher; + + if (encryptionKey == null || encryptionKey.length() != 16) { + throw new IllegalArgumentException("SECRET_ENCRYPTION_KEY must have a length of 16 characters!<" + encryptionKey + ">"); + } + + this.encryptionKey = encryptionKey; + this.secretRepository = secretRepository; + } + + public void saveSecret(String key, String environmentVariable, String value, List deployments, boolean restartDeployments) { + try { + + Secret secret = secretRepository.findById(key).orElseGet(() -> new Secret(key, environmentVariable, value, deployments)); + + List oldDeployments = new ArrayList<>(secret.getDeployments()); + + secret.setDeployments(deployments); + secret.setEnvironmentVariable(environmentVariable); + + if(value != null ) + secret.setEncryptedValue(encrypt(value)); + + secretRepository.save(secret); + + log.info("Saved Secret <{}>", key); + + if(restartDeployments) { + + List deploymentsToRestart = new HashSet( + Stream.concat(oldDeployments.stream(), deployments.stream()).toList()).stream().toList(); + + applicationEventPublisher.publishEvent(new MultipleNamedDeploymentsStartRequestedEvent(this, deploymentsToRestart, true)); + } + + } catch (Exception e) { + throw new RuntimeException("Saving Secret failed", e); + } + } + + public Map getAllSecretsAsEnvironmentVariableMapByDeployment(String deployment) { + List secrets = secretRepository.findAllByDeploymentsContaining(deployment); + Map result = new HashMap<>(); + + for (Secret secret : secrets) { + try { + result.put(secret.getEnvironmentVariable(), decrypt(secret.getEncryptedValue())); + } catch(Exception ex) { + log.error("Failed to decrypt secret <{}> for Env Var <{}> and Deployment <{}>.", secret.getKey(), secret.getEnvironmentVariable(), deployment); + throw new RuntimeException(ex); + } + } + + return result; + } + + public List getAllSecretsWithoutValues() { + return secretRepository.findAll().stream().map( + s -> new SecretDto(s.getKey(), s.getEnvironmentVariable(), null, s.getDeployments()) + ).toList(); + } + + public void deleteSecret(String key, boolean restartDeployments) { + Optional byId = this.secretRepository.findById(key); + + if(byId.isEmpty()) return; + + Secret secret = byId.get(); + secretRepository.deleteById(key); + + log.debug("Deleted Secret <{}>", key); + + if(restartDeployments) + applicationEventPublisher.publishEvent(new MultipleNamedDeploymentsStartRequestedEvent(this, secret.getDeployments(), true)); + } + + private String encrypt(String value) throws Exception { + SecretKeySpec keySpec = new SecretKeySpec(encryptionKey.getBytes(), ALGORITHM); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec); + return Base64.getEncoder().encodeToString(cipher.doFinal(value.getBytes())); + } + + private String decrypt(String encrypted) throws Exception { + + if(encrypted == null) + return null; + + SecretKeySpec keySpec = new SecretKeySpec(encryptionKey.getBytes(), ALGORITHM); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, keySpec); + return new String(cipher.doFinal(Base64.getDecoder().decode(encrypted))); + } + +} + diff --git a/backend/src/main/resources/application-local.properties.template b/backend/src/main/resources/application-local.properties.template index 14d6b23..77c3b69 100644 --- a/backend/src/main/resources/application-local.properties.template +++ b/backend/src/main/resources/application-local.properties.template @@ -1,3 +1,4 @@ dirigent.deployments.git.url= dirigent.git.authToken= spring.h2.console.enabled=true +dirigent.secrets.encryption.key= diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 9ea7fa8..66ab50b 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,11 +1,8 @@ spring.application.name=dirigent spring.profiles.active=local -spring.datasource.url=jdbc:h2:file:./data/statedb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password=password -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect -spring.h2.console.enabled=false +spring.datasource.url=jdbc:sqlite:data/dirigent.db +spring.datasource.driver-class-name=org.sqlite.JDBC +spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect spring.jpa.hibernate.ddl-auto=update dirigent.git.authToken= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 44f969b..13bca04 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -443,7 +443,6 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.19.tgz", "integrity": "sha512-PCpJagurPBqciqcq4Z8+3OtKLb7rSl4w/qBJoIMua8CgnrjvA1i+SWawhdtfI1zlY8FSwhzLwXV0CmWWfFzQPg==", "license": "MIT", - "peer": true, "dependencies": { "parse5": "^7.1.2", "tslib": "^2.3.0" @@ -493,7 +492,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.15.tgz", "integrity": "sha512-aVa/ctBYH/4qgA7r4sS7TV+/DzRYmcS+3d6l89pNKUXkI8gpmsd+r3FjccaemX4Wqru1QOrMvC+i+e7IBIVv0g==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -510,7 +508,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.15.tgz", "integrity": "sha512-hMHZU6/03xG0tbPDIm1hbVSTFLnRkGYfh+xdBwUMnIFYYTS0QJ2hdPfEZKCJIXm+fz9IAI5MPdDTfeyp0sgaHQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -524,7 +521,6 @@ "integrity": "sha512-4r5tvGA2Ok3o8wROZBkF9qNKS7L0AEpdBIkAVJbLw2rBY2SlyycFIRYyV2+D1lJ1jq/f9U7uN6oon0MjTvNYkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.26.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -601,7 +597,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.15.tgz", "integrity": "sha512-PxhzCwwm23N4Mq6oV7UPoYiJF4r6FzGhRSxOBBlEp322k7zEQbIxd/XO6F3eoG73qC1UsOXMYYv6GnQpx42y3A==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -618,7 +613,6 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.15.tgz", "integrity": "sha512-pZDElcYPmNzPxvWJpZQCIizsNApDIfk9xLJE4I8hzLISfWGbQvfjuuarDAuQZEXudeLXoDOstDXkDja40muLGg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -654,7 +648,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.15.tgz", "integrity": "sha512-OelQ6weCjon8kZD8kcqNzwugvZJurjS3uMJCwsA2vXmP/3zJ31SWtNqE2zLT1R2csVuwnp0h+nRMgq+pINU7Rg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -739,7 +732,6 @@ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -3038,7 +3030,6 @@ "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.1.2", "@inquirer/confirm": "^5.1.6", @@ -5490,7 +5481,6 @@ "integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.8.0" } @@ -5866,7 +5856,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6330,7 +6319,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", @@ -9319,8 +9307,7 @@ "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.12.0.tgz", "integrity": "sha512-QqO4pX33GEML5JoGQU6BM5NHKPgEsg+TXp3jCIDek9MbfEp2JUYEFBo9EF1+hegWy/bCHS1m5nP0BOp18G6rVA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/jest-worker": { "version": "27.5.1", @@ -9465,7 +9452,6 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -9824,7 +9810,6 @@ "integrity": "sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -11968,7 +11953,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12628,7 +12612,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -13748,7 +13731,6 @@ "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -13901,8 +13883,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tuf-js": { "version": "3.0.1", @@ -13959,7 +13940,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14640,7 +14620,6 @@ "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -14718,7 +14697,6 @@ "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -15285,8 +15263,7 @@ "version": "0.15.1", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", - "license": "MIT", - "peer": true + "license": "MIT" } } } diff --git a/frontend/src/app/overview/api.service.ts b/frontend/src/app/api/api.service.ts similarity index 50% rename from frontend/src/app/overview/api.service.ts rename to frontend/src/app/api/api.service.ts index effb270..4eb530c 100644 --- a/frontend/src/app/overview/api.service.ts +++ b/frontend/src/app/api/api.service.ts @@ -1,8 +1,9 @@ import {Injectable} from '@angular/core'; -import {Observable, ReplaySubject} from 'rxjs'; -import {Deployment} from './deploymentState'; +import {Observable, ReplaySubject, tap} from 'rxjs'; +import {Deployment} from './deployment'; import {HttpClient} from '@angular/common/http'; -import { SystemInformation } from './system-information'; +import {Secret} from './secret'; +import {SystemInformation} from './system-information'; @Injectable({ providedIn: 'root' @@ -10,6 +11,7 @@ import { SystemInformation } from './system-information'; export class ApiService { private _deploymentStates: ReplaySubject> = new ReplaySubject>(1); + private _secrets: ReplaySubject> = new ReplaySubject>(1); constructor(private http: HttpClient) { } @@ -18,6 +20,10 @@ export class ApiService { return this._deploymentStates.asObservable(); } + get secrets$(): Observable> { + return this._secrets.asObservable(); + } + updateDeploymentStates(): void { this.getAllDeploymentStates().subscribe(r => this._deploymentStates.next(r)); } @@ -35,6 +41,26 @@ export class ApiService { } startDeployment(deploymentState: Deployment, force: boolean): Observable { - return this.http.post(`api/v1/deployments/${deploymentState.name}/start?force=${force}`, {}); + return this.http.post(`api/v1/deployments/${deploymentState.name}/start?forceRecreate=${force}`, {}); + } + + getSecrets(): Observable> { + return this.http.get>('api/v1/secrets'); + } + + reloadSecrets(): void { + this.getSecrets().subscribe((r) => this._secrets.next(r)); + } + + putSecret(secret: Secret, restartDeployments: boolean): Observable { + return this.http.put(`api/v1/secrets/${secret.key}?restartDeployments=${restartDeployments}`, secret).pipe( + tap(() => this.reloadSecrets()) + ); + } + + deleteSecret(secret: Secret, restartDeployments: boolean): Observable { + return this.http.delete(`api/v1/secrets/${secret.key}?restartDeployments=${restartDeployments}`).pipe( + tap(() => this.reloadSecrets()) + ); } } diff --git a/frontend/src/app/overview/deploymentState.d.ts b/frontend/src/app/api/deployment.d.ts similarity index 100% rename from frontend/src/app/overview/deploymentState.d.ts rename to frontend/src/app/api/deployment.d.ts diff --git a/frontend/src/app/api/secret.d.ts b/frontend/src/app/api/secret.d.ts new file mode 100644 index 0000000..5fe4817 --- /dev/null +++ b/frontend/src/app/api/secret.d.ts @@ -0,0 +1,6 @@ +export interface Secret { + key: string | null; + value: string | null; + environmentVariable: string; + deployments: Array; +} diff --git a/frontend/src/app/overview/system-information.d.ts b/frontend/src/app/api/system-information.d.ts similarity index 100% rename from frontend/src/app/overview/system-information.d.ts rename to frontend/src/app/api/system-information.d.ts diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 7f2200e..aee6c26 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -3,5 +3,8 @@ Icon Dirigent - + + + + diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 9db3c14..fb2e037 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,9 +1,11 @@ import {Component} from '@angular/core'; -import {RouterOutlet} from '@angular/router'; +import {MatTab, MatTabGroup} from '@angular/material/tabs'; +import {DeploymentsComponent} from './deployments/deployments.component'; +import {SecretsComponent} from './secrets/secrets.component'; @Component({ selector: 'app-root', - imports: [RouterOutlet], + imports: [MatTabGroup, MatTab, DeploymentsComponent, SecretsComponent], templateUrl: './app.component.html', styleUrl: './app.component.css' }) diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index fe4326c..1168fbd 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,9 +1,9 @@ import {Routes} from '@angular/router'; -import {OverviewComponent} from './overview/overview.component'; +import {DeploymentsComponent} from './deployments/deployments.component'; export const routes: Routes = [ { path: '', - component: OverviewComponent + component: DeploymentsComponent } ]; diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/deployments/deployments.component.css similarity index 100% rename from frontend/src/app/app.component.spec.ts rename to frontend/src/app/deployments/deployments.component.css diff --git a/frontend/src/app/overview/overview.component.html b/frontend/src/app/deployments/deployments.component.html similarity index 100% rename from frontend/src/app/overview/overview.component.html rename to frontend/src/app/deployments/deployments.component.html diff --git a/frontend/src/app/overview/overview.component.ts b/frontend/src/app/deployments/deployments.component.ts similarity index 91% rename from frontend/src/app/overview/overview.component.ts rename to frontend/src/app/deployments/deployments.component.ts index f610cf9..0b37522 100644 --- a/frontend/src/app/overview/overview.component.ts +++ b/frontend/src/app/deployments/deployments.component.ts @@ -11,8 +11,8 @@ import { MatRowDef, MatTable } from '@angular/material/table'; -import {Deployment} from './deploymentState'; -import {ApiService} from './api.service'; +import {Deployment} from '../api/deployment'; +import {ApiService} from '../api/api.service'; import {MatMenu, MatMenuItem, MatMenuTrigger} from '@angular/material/menu'; import {MatIcon} from '@angular/material/icon'; import {MatChip, MatChipListbox, MatChipListboxChange, MatChipOption} from '@angular/material/chips'; @@ -25,10 +25,10 @@ import {AsyncPipe} from '@angular/common'; import {FormsModule} from '@angular/forms'; import {MatSort, MatSortHeader, Sort} from '@angular/material/sort'; import {MatFormField, MatInput, MatLabel} from '@angular/material/input'; -import { SystemInformation } from './system-information'; +import { SystemInformation } from '../api/system-information'; @Component({ - selector: 'app-overview', + selector: 'app-deployments', imports: [ MatTable, MatColumnDef, @@ -56,10 +56,10 @@ import { SystemInformation } from './system-information'; MatInput, MatFormField ], - templateUrl: './overview.component.html', - styleUrl: './overview.component.css', + templateUrl: './deployments.component.html', + styleUrl: './deployments.component.css', }) -export class OverviewComponent implements OnInit { +export class DeploymentsComponent implements OnInit { selectedFilterValues$ = new ReplaySubject>(1); sort$ = new ReplaySubject(1); @@ -120,7 +120,7 @@ export class OverviewComponent implements OnInit { } ngOnInit(): void { - this.selectedFilterValues$.next(['RUNNING', 'FAILED', 'STOPPED']); + this.selectedFilterValues$.next(['RUNNING', 'STOPPED', 'FAILED', 'UPDATED', 'UNKNOWN', 'STARTING', 'STOPPING']); this.sort$.next({active: 'name', direction: 'asc'}); this.search$.next(''); } diff --git a/frontend/src/app/overview/start-dialog/start-dialog.component.css b/frontend/src/app/deployments/start-dialog/start-dialog.component.css similarity index 100% rename from frontend/src/app/overview/start-dialog/start-dialog.component.css rename to frontend/src/app/deployments/start-dialog/start-dialog.component.css diff --git a/frontend/src/app/overview/start-dialog/start-dialog.component.html b/frontend/src/app/deployments/start-dialog/start-dialog.component.html similarity index 100% rename from frontend/src/app/overview/start-dialog/start-dialog.component.html rename to frontend/src/app/deployments/start-dialog/start-dialog.component.html diff --git a/frontend/src/app/overview/start-dialog/start-dialog.component.ts b/frontend/src/app/deployments/start-dialog/start-dialog.component.ts similarity index 100% rename from frontend/src/app/overview/start-dialog/start-dialog.component.ts rename to frontend/src/app/deployments/start-dialog/start-dialog.component.ts diff --git a/frontend/src/app/overview/overview.component.css b/frontend/src/app/secrets/edit-secret-dialog/edit-secret-dialog.component.css similarity index 100% rename from frontend/src/app/overview/overview.component.css rename to frontend/src/app/secrets/edit-secret-dialog/edit-secret-dialog.component.css diff --git a/frontend/src/app/secrets/edit-secret-dialog/edit-secret-dialog.component.html b/frontend/src/app/secrets/edit-secret-dialog/edit-secret-dialog.component.html new file mode 100644 index 0000000..c4d9799 --- /dev/null +++ b/frontend/src/app/secrets/edit-secret-dialog/edit-secret-dialog.component.html @@ -0,0 +1,72 @@ + + + @if (originalSecret.key) { +

Edit Secret

+ } + @if (!originalSecret.key) { + + Key + + + } + + + + Environment Variable + + + + + + Value + + + + + + + Deployments + + @for (deployment of secret.deployments; track deployment) { + + + {{deployment}} + + + + } + + + @for (deploymentName of $deploymentNames | async; track deploymentName) { + {{deploymentName}} + } + + + + +
+ + + Restart Deployments + + + @if (originalSecret.key && !sureDelete) { + + } + @if (originalSecret.key && sureDelete) { + + } + + diff --git a/frontend/src/app/secrets/edit-secret-dialog/edit-secret-dialog.component.ts b/frontend/src/app/secrets/edit-secret-dialog/edit-secret-dialog.component.ts new file mode 100644 index 0000000..fad8913 --- /dev/null +++ b/frontend/src/app/secrets/edit-secret-dialog/edit-secret-dialog.component.ts @@ -0,0 +1,125 @@ +import {ChangeDetectionStrategy, Component, inject, Inject} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogRef} from '@angular/material/dialog'; +import {Secret} from '../../api/secret'; +import {FormsModule} from '@angular/forms'; +import {MatFormField, MatInput, MatLabel} from '@angular/material/input'; +import {MatButton} from '@angular/material/button'; +import {MatChipGrid, MatChipInput, MatChipInputEvent, MatChipRemove, MatChipRow} from '@angular/material/chips'; +import {ENTER, SPACE} from '@angular/cdk/keycodes'; +import {MatIcon} from '@angular/material/icon'; +import {ApiService} from '../../api/api.service'; +import {Observable, ReplaySubject} from 'rxjs'; +import {map, switchMap} from 'rxjs/operators'; +import { + MatAutocomplete, + MatAutocompleteSelectedEvent, + MatAutocompleteTrigger, + MatOption +} from '@angular/material/autocomplete'; +import {AsyncPipe} from '@angular/common'; +import { MatCheckbox } from "@angular/material/checkbox"; + +@Component({ + selector: 'app-edit-secret-dialog', + imports: [ + FormsModule, + MatInput, + MatFormField, + MatLabel, + MatButton, + MatDialogContent, + MatDialogActions, + MatChipGrid, + MatChipRow, + MatIcon, + MatChipInput, + MatChipRemove, + MatAutocompleteTrigger, + MatAutocomplete, + AsyncPipe, + MatOption, + MatCheckbox +], + templateUrl: './edit-secret-dialog.component.html', + styleUrl: './edit-secret-dialog.component.css', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EditSecretDialogComponent { + + protected readonly ENTER = ENTER; + protected readonly SPACE = SPACE; + readonly dialogRef = inject(MatDialogRef); + secret: Secret; + originalSecret: Secret; + sureDelete: boolean = false; + restartDeployments: boolean = false; + $deploymentNames: Observable>; + $deploymentNamesFilter: ReplaySubject = new ReplaySubject(1); + + constructor(@Inject(MAT_DIALOG_DATA) public data: Secret, private apiService: ApiService) { + this.secret = structuredClone(data); + this.originalSecret = structuredClone(data); + + this.$deploymentNames = apiService.deploymentStates$.pipe( + switchMap(ds => this.$deploymentNamesFilter.pipe(map(filter => ds.filter(ds => ds.name.toLowerCase().includes(filter.toLowerCase()))))), + map(ds => + ds.filter(ds => !this.secret.deployments.includes(ds.name)) + .map(ds => ds.name) + .sort((a, b) => a.localeCompare(b)) + ) + + ); + + this.$deploymentNamesFilter.next(''); + } + + changed(): boolean { + return JSON.stringify(this.secret) !== JSON.stringify(this.originalSecret); + } + + delete() { + + if (!this.sureDelete) { + this.sureDelete = true; + return; + } + this.apiService.deleteSecret(this.secret, this.restartDeployments).subscribe(() => this.dialogRef.close()); + } + + save() { + this.apiService.putSecret(this.secret, this.restartDeployments).subscribe(() => this.dialogRef.close()); + } + + cancel() { + this.dialogRef.close(); + } + + removeDeployment(deployment: string) { + this.secret.deployments = this.secret.deployments.filter(d => d !== deployment); + } + + addDeploymentFromInput($event: MatChipInputEvent) { + + const value = $event.value; + $event.chipInput.clear(); + this.addDeployment(value); + } + + private addDeployment(value: string) { + if (value.trim().length === 0) return; + + + if (this.secret.deployments.includes(value)) return; + + this.secret.deployments.push(value); + } + + addDeploymentFromAutoComplete($event: MatAutocompleteSelectedEvent) { + this.addDeployment($event.option.viewValue); + $event.option.deselect(); + } + + filterDeployments($event: Event) { + this.$deploymentNamesFilter.next(($event.target as HTMLInputElement).value); + } +} diff --git a/frontend/src/app/secrets/secrets.component.css b/frontend/src/app/secrets/secrets.component.css new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/src/app/secrets/secrets.component.css @@ -0,0 +1 @@ + diff --git a/frontend/src/app/secrets/secrets.component.html b/frontend/src/app/secrets/secrets.component.html new file mode 100644 index 0000000..f0fa15b --- /dev/null +++ b/frontend/src/app/secrets/secrets.component.html @@ -0,0 +1,45 @@ +

Your Secrets

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Actions + + Name {{ element.key }}Environment Variable {{ element.environmentVariable }}Deployments + + + @for (deployment of element.deployments; track deployment) { + + {{deployment}} + + } + + +
diff --git a/frontend/src/app/secrets/secrets.component.ts b/frontend/src/app/secrets/secrets.component.ts new file mode 100644 index 0000000..803dfd7 --- /dev/null +++ b/frontend/src/app/secrets/secrets.component.ts @@ -0,0 +1,74 @@ +import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; +import {ApiService} from '../api/api.service'; +import {Observable} from 'rxjs'; +import {Secret} from '../api/secret'; +import { + MatCell, + MatCellDef, + MatColumnDef, + MatHeaderCell, + MatHeaderCellDef, + MatHeaderRow, + MatHeaderRowDef, + MatRow, + MatRowDef, + MatTable +} from '@angular/material/table'; +import {MatIcon} from '@angular/material/icon'; +import {MatChip, MatChipSet} from '@angular/material/chips'; +import {MatButton, MatIconButton} from '@angular/material/button'; +import {MatDialog} from '@angular/material/dialog'; +import {EditSecretDialogComponent} from './edit-secret-dialog/edit-secret-dialog.component'; + +@Component({ + selector: 'app-secrets', + imports: [ + MatCell, + MatCellDef, + MatColumnDef, + MatHeaderCell, + MatIcon, + MatTable, + MatHeaderCellDef, + MatHeaderRowDef, + MatRowDef, + MatHeaderRow, + MatRow, + MatIconButton, + MatChipSet, + MatChip, + MatButton, + ], + templateUrl: './secrets.component.html', + styleUrl: './secrets.component.css', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SecretsComponent { + + secrets$: Observable>; + displayedColumns = ['actions', 'key', 'environmentVariable', 'deployments']; + + readonly dialog = inject(MatDialog); + + constructor(private apiService: ApiService) { + this.apiService.reloadSecrets(); + this.secrets$ = apiService.secrets$; + } + + edit(element: Secret) { + this.dialog.open(EditSecretDialogComponent, { + data: element + }); + } + + add() { + this.dialog.open(EditSecretDialogComponent, { + data: { + key: null, + value: null, + environmentVariable: '', + deployments: [] + } as Secret, + }); + } +}