implements packer PR-05 sensitive mutations and studio write adapter
This commit is contained in:
parent
924ab587e8
commit
c4fc6e0041
@ -3,6 +3,7 @@ package p.packer.api.mutations;
|
||||
import p.packer.api.PackerOperationStatus;
|
||||
import p.packer.api.diagnostics.PackerDiagnostic;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@ -14,7 +15,10 @@ public record PackerMutationPreview(
|
||||
List<PackerProposedAction> proposedActions,
|
||||
List<PackerDiagnostic> diagnostics,
|
||||
List<String> blockers,
|
||||
boolean highRisk) {
|
||||
List<String> warnings,
|
||||
List<String> safeFixes,
|
||||
boolean highRisk,
|
||||
Path targetAssetRoot) {
|
||||
|
||||
public PackerMutationPreview {
|
||||
Objects.requireNonNull(status, "status");
|
||||
@ -24,6 +28,9 @@ public record PackerMutationPreview(
|
||||
proposedActions = List.copyOf(Objects.requireNonNull(proposedActions, "proposedActions"));
|
||||
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
|
||||
blockers = List.copyOf(Objects.requireNonNull(blockers, "blockers"));
|
||||
warnings = List.copyOf(Objects.requireNonNull(warnings, "warnings"));
|
||||
safeFixes = List.copyOf(Objects.requireNonNull(safeFixes, "safeFixes"));
|
||||
targetAssetRoot = targetAssetRoot == null ? null : targetAssetRoot.toAbsolutePath().normalize();
|
||||
if (summary.isBlank() || operationId.isBlank()) {
|
||||
throw new IllegalArgumentException("summary and operationId must not be blank");
|
||||
}
|
||||
|
||||
@ -0,0 +1,412 @@
|
||||
package p.packer.mutations;
|
||||
|
||||
import p.packer.api.PackerOperationClass;
|
||||
import p.packer.api.PackerOperationStatus;
|
||||
import p.packer.api.PackerProjectContext;
|
||||
import p.packer.api.assets.PackerAssetDetails;
|
||||
import p.packer.api.assets.PackerAssetState;
|
||||
import p.packer.api.diagnostics.PackerDiagnostic;
|
||||
import p.packer.api.events.PackerEvent;
|
||||
import p.packer.api.events.PackerEventKind;
|
||||
import p.packer.api.events.PackerEventSink;
|
||||
import p.packer.api.mutations.PackerMutationPreview;
|
||||
import p.packer.api.mutations.PackerMutationRequest;
|
||||
import p.packer.api.mutations.PackerMutationResult;
|
||||
import p.packer.api.mutations.PackerMutationService;
|
||||
import p.packer.api.mutations.PackerMutationType;
|
||||
import p.packer.api.mutations.PackerProposedAction;
|
||||
import p.packer.api.workspace.GetAssetDetailsRequest;
|
||||
import p.packer.declarations.PackerAssetDetailsService;
|
||||
import p.packer.foundation.PackerRegistryEntry;
|
||||
import p.packer.foundation.PackerRegistryState;
|
||||
import p.packer.foundation.PackerWorkspaceFoundation;
|
||||
import p.packer.foundation.PackerWorkspacePaths;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class FileSystemPackerMutationService implements PackerMutationService {
|
||||
private static final String RECOVERED_DIR = "recovered";
|
||||
private static final String QUARANTINE_DIR = "quarantine";
|
||||
|
||||
private final PackerWorkspaceFoundation workspaceFoundation;
|
||||
private final PackerAssetDetailsService detailsService;
|
||||
private final PackerProjectWriteCoordinator writeCoordinator;
|
||||
private final PackerEventSink eventSink;
|
||||
|
||||
public FileSystemPackerMutationService() {
|
||||
this(new PackerWorkspaceFoundation(), new PackerAssetDetailsService(), new PackerProjectWriteCoordinator(), PackerEventSink.noop());
|
||||
}
|
||||
|
||||
public FileSystemPackerMutationService(
|
||||
PackerWorkspaceFoundation workspaceFoundation,
|
||||
PackerAssetDetailsService detailsService,
|
||||
PackerProjectWriteCoordinator writeCoordinator,
|
||||
PackerEventSink eventSink) {
|
||||
this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation");
|
||||
this.detailsService = Objects.requireNonNull(detailsService, "detailsService");
|
||||
this.writeCoordinator = Objects.requireNonNull(writeCoordinator, "writeCoordinator");
|
||||
this.eventSink = Objects.requireNonNull(eventSink, "eventSink");
|
||||
}
|
||||
|
||||
@Override
|
||||
public PackerOperationClass operationClass() {
|
||||
return PackerOperationClass.WORKSPACE_MUTATION;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PackerMutationPreview preview(PackerMutationRequest request) {
|
||||
final ResolvedMutationContext context = resolveContext(Objects.requireNonNull(request, "request"));
|
||||
final PackerMutationPreview preview = buildPreview(context);
|
||||
emit(context.project(), preview.operationId(), 0L, PackerEventKind.PREVIEW_READY, preview.summary(), affectedAssets(preview));
|
||||
return preview;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PackerMutationResult apply(PackerMutationPreview preview) {
|
||||
Objects.requireNonNull(preview, "preview");
|
||||
final PackerProjectContext project = preview.request().project();
|
||||
try {
|
||||
if (!preview.canApply()) {
|
||||
throw new PackerMutationException("Cannot apply mutation preview with blockers");
|
||||
}
|
||||
final PackerMutationResult result = writeCoordinator.withWriteLock(project, () -> applyLocked(preview));
|
||||
emit(project, result.operationId(), 1L, PackerEventKind.ACTION_APPLIED, result.summary(), affectedAssets(preview));
|
||||
return result;
|
||||
} catch (RuntimeException exception) {
|
||||
emit(project, preview.operationId(), 1L, PackerEventKind.ACTION_FAILED, rootCauseMessage(exception), affectedAssets(preview));
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private PackerMutationResult applyLocked(PackerMutationPreview preview) {
|
||||
final PackerProjectContext project = preview.request().project();
|
||||
final PackerRegistryState registry = workspaceFoundation.loadRegistry(project);
|
||||
final ResolvedMutationContext context = resolveContext(preview.request());
|
||||
final Path assetRoot = context.assetDetails().summary().identity().assetRoot();
|
||||
|
||||
return switch (preview.request().type()) {
|
||||
case REGISTER_ASSET, ADOPT_ASSET -> applyRegister(project, registry, assetRoot, preview);
|
||||
case FORGET_ASSET -> applyForget(project, registry, assetRoot, preview);
|
||||
case REMOVE_ASSET -> applyRemove(project, registry, assetRoot, preview);
|
||||
case QUARANTINE_ASSET -> applyQuarantine(project, registry, assetRoot, preview);
|
||||
case RELOCATE_ASSET -> applyRelocate(project, registry, assetRoot, preview);
|
||||
};
|
||||
}
|
||||
|
||||
private PackerMutationResult applyRegister(
|
||||
PackerProjectContext project,
|
||||
PackerRegistryState registry,
|
||||
Path assetRoot,
|
||||
PackerMutationPreview preview) {
|
||||
final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(project, assetRoot);
|
||||
final Optional<PackerRegistryEntry> existing = workspaceFoundation.lookup().findByRoot(project, registry, assetRoot);
|
||||
if (existing.isPresent()) {
|
||||
return new PackerMutationResult(
|
||||
PackerOperationStatus.SUCCESS,
|
||||
"Asset is already registered.",
|
||||
preview.operationId(),
|
||||
List.of(),
|
||||
List.of());
|
||||
}
|
||||
final PackerRegistryEntry entry = workspaceFoundation.allocateIdentity(project, registry, assetRoot);
|
||||
final PackerRegistryState updated = workspaceFoundation.appendAllocatedEntry(registry, entry);
|
||||
workspaceFoundation.saveRegistry(project, updated);
|
||||
return new PackerMutationResult(
|
||||
PackerOperationStatus.SUCCESS,
|
||||
"Asset registered: " + relativeRoot,
|
||||
preview.operationId(),
|
||||
preview.proposedActions(),
|
||||
List.of());
|
||||
}
|
||||
|
||||
private PackerMutationResult applyForget(
|
||||
PackerProjectContext project,
|
||||
PackerRegistryState registry,
|
||||
Path assetRoot,
|
||||
PackerMutationPreview preview) {
|
||||
final PackerRegistryState updated = registry.withAssets(
|
||||
registry.assets().stream()
|
||||
.filter(entry -> !PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot))
|
||||
.toList(),
|
||||
registry.nextAssetId());
|
||||
workspaceFoundation.saveRegistry(project, updated);
|
||||
return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset forgotten.", preview.operationId(), preview.proposedActions(), List.of());
|
||||
}
|
||||
|
||||
private PackerMutationResult applyRemove(
|
||||
PackerProjectContext project,
|
||||
PackerRegistryState registry,
|
||||
Path assetRoot,
|
||||
PackerMutationPreview preview) {
|
||||
final PackerRegistryState updated = registry.withAssets(
|
||||
registry.assets().stream()
|
||||
.filter(entry -> !PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot))
|
||||
.toList(),
|
||||
registry.nextAssetId());
|
||||
workspaceFoundation.saveRegistry(project, updated);
|
||||
deleteRecursively(assetRoot);
|
||||
return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset removed from workspace.", preview.operationId(), preview.proposedActions(), List.of());
|
||||
}
|
||||
|
||||
private PackerMutationResult applyQuarantine(
|
||||
PackerProjectContext project,
|
||||
PackerRegistryState registry,
|
||||
Path assetRoot,
|
||||
PackerMutationPreview preview) {
|
||||
final PackerRegistryState updated = registry.withAssets(
|
||||
registry.assets().stream()
|
||||
.filter(entry -> !PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot))
|
||||
.toList(),
|
||||
registry.nextAssetId());
|
||||
workspaceFoundation.saveRegistry(project, updated);
|
||||
moveAssetRoot(assetRoot, requireTarget(preview));
|
||||
return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset moved to quarantine.", preview.operationId(), preview.proposedActions(), List.of());
|
||||
}
|
||||
|
||||
private PackerMutationResult applyRelocate(
|
||||
PackerProjectContext project,
|
||||
PackerRegistryState registry,
|
||||
Path assetRoot,
|
||||
PackerMutationPreview preview) {
|
||||
final Path targetRoot = requireTarget(preview);
|
||||
final PackerRegistryState updated = registry.withAssets(
|
||||
registry.assets().stream()
|
||||
.map(entry -> PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot)
|
||||
? new PackerRegistryEntry(entry.assetId(), entry.assetUuid(), PackerWorkspacePaths.relativeAssetRoot(project, targetRoot))
|
||||
: entry)
|
||||
.toList(),
|
||||
registry.nextAssetId());
|
||||
workspaceFoundation.saveRegistry(project, updated);
|
||||
moveAssetRoot(assetRoot, targetRoot);
|
||||
return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset root relocated.", preview.operationId(), preview.proposedActions(), List.of());
|
||||
}
|
||||
|
||||
private PackerMutationPreview buildPreview(ResolvedMutationContext context) {
|
||||
final PackerMutationRequest request = context.request();
|
||||
final PackerAssetDetails assetDetails = context.assetDetails();
|
||||
final Path assetRoot = assetDetails.summary().identity().assetRoot();
|
||||
final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(context.project(), assetRoot);
|
||||
final boolean managed = assetDetails.summary().identity().assetId() != null;
|
||||
final List<PackerProposedAction> actions = new ArrayList<>();
|
||||
final List<String> blockers = new ArrayList<>(context.initialBlockers());
|
||||
final List<String> warnings = new ArrayList<>();
|
||||
final List<String> safeFixes = new ArrayList<>();
|
||||
Path targetAssetRoot = null;
|
||||
|
||||
switch (request.type()) {
|
||||
case REGISTER_ASSET, ADOPT_ASSET -> {
|
||||
if (managed) {
|
||||
blockers.add("Asset is already managed.");
|
||||
}
|
||||
if (assetDetails.summary().state() == PackerAssetState.INVALID) {
|
||||
blockers.add("Asset declaration must be valid before registration.");
|
||||
}
|
||||
if (!blockers.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "ADD", relativeRoot));
|
||||
if (assetDetails.summary().hasDiagnostics()) {
|
||||
warnings.add("Asset currently reports diagnostics and will still be registered.");
|
||||
}
|
||||
}
|
||||
case FORGET_ASSET -> {
|
||||
if (!managed) {
|
||||
blockers.add("Only managed assets can be forgotten.");
|
||||
} else {
|
||||
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "REMOVE", relativeRoot));
|
||||
warnings.add("The asset will leave the managed build set.");
|
||||
}
|
||||
}
|
||||
case REMOVE_ASSET -> {
|
||||
if (managed) {
|
||||
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "REMOVE", relativeRoot));
|
||||
}
|
||||
actions.add(new PackerProposedAction(PackerOperationClass.WORKSPACE_MUTATION, "DELETE", relativeRoot));
|
||||
warnings.add("Physical files inside the asset root will be deleted.");
|
||||
}
|
||||
case QUARANTINE_ASSET -> {
|
||||
if (isInsideQuarantine(context.project(), assetRoot)) {
|
||||
blockers.add("Asset is already inside quarantine.");
|
||||
} else {
|
||||
targetAssetRoot = request.targetRoot() != null
|
||||
? request.targetRoot()
|
||||
: nextAvailablePath(quarantineRoot(context.project()), sanitizeSegment(assetDetails.summary().identity().assetName()));
|
||||
if (managed) {
|
||||
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "REMOVE", relativeRoot));
|
||||
warnings.add("Quarantining a managed asset removes it from the active registry.");
|
||||
}
|
||||
actions.add(new PackerProposedAction(
|
||||
PackerOperationClass.WORKSPACE_MUTATION,
|
||||
"MOVE",
|
||||
relativeRoot + " -> " + PackerWorkspacePaths.relativeAssetRoot(context.project(), targetAssetRoot)));
|
||||
warnings.add("Quarantine is explicit and reversible, but the asset will leave its current workspace location.");
|
||||
}
|
||||
}
|
||||
case RELOCATE_ASSET -> {
|
||||
targetAssetRoot = request.targetRoot() != null
|
||||
? request.targetRoot()
|
||||
: relocationTarget(context.project(), assetRoot, assetDetails.summary().identity().assetName());
|
||||
final String targetRelativeRoot = PackerWorkspacePaths.relativeAssetRoot(context.project(), targetAssetRoot);
|
||||
if (assetRoot.equals(targetAssetRoot)) {
|
||||
blockers.add("Asset is already at the planned relocation target.");
|
||||
} else {
|
||||
if (managed) {
|
||||
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "UPDATE", relativeRoot + " -> " + targetRelativeRoot));
|
||||
}
|
||||
actions.add(new PackerProposedAction(PackerOperationClass.WORKSPACE_MUTATION, "MOVE", relativeRoot + " -> " + targetRelativeRoot));
|
||||
warnings.add("Relocation preserves asset identity, but it changes the root path seen by the workspace.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new PackerMutationPreview(
|
||||
blockers.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL,
|
||||
"Mutation preview ready for " + request.type().name().toLowerCase(),
|
||||
UUID.randomUUID().toString(),
|
||||
request,
|
||||
actions,
|
||||
assetDetails.diagnostics(),
|
||||
blockers,
|
||||
warnings,
|
||||
safeFixes,
|
||||
request.type() == PackerMutationType.REMOVE_ASSET || request.type() == PackerMutationType.RELOCATE_ASSET,
|
||||
targetAssetRoot);
|
||||
}
|
||||
|
||||
private ResolvedMutationContext resolveContext(PackerMutationRequest request) {
|
||||
final PackerAssetDetails assetDetails = detailsService.getAssetDetails(
|
||||
new GetAssetDetailsRequest(request.project(), request.assetReference())).details();
|
||||
final List<String> initialBlockers = assetDetails.diagnostics().stream()
|
||||
.filter(PackerDiagnostic::blocking)
|
||||
.map(PackerDiagnostic::message)
|
||||
.filter(message -> request.type() != PackerMutationType.FORGET_ASSET
|
||||
&& request.type() != PackerMutationType.REMOVE_ASSET
|
||||
&& request.type() != PackerMutationType.QUARANTINE_ASSET
|
||||
&& request.type() != PackerMutationType.RELOCATE_ASSET)
|
||||
.toList();
|
||||
return new ResolvedMutationContext(request.project(), request, assetDetails, initialBlockers);
|
||||
}
|
||||
|
||||
private Path quarantineRoot(PackerProjectContext project) {
|
||||
return PackerWorkspacePaths.registryDirectory(project).resolve(QUARANTINE_DIR).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private boolean isInsideQuarantine(PackerProjectContext project, Path assetRoot) {
|
||||
return assetRoot.toAbsolutePath().normalize().startsWith(quarantineRoot(project));
|
||||
}
|
||||
|
||||
private Path relocationTarget(PackerProjectContext project, Path assetRoot, String assetName) {
|
||||
if (isInsideQuarantine(project, assetRoot)) {
|
||||
return nextAvailablePath(PackerWorkspacePaths.assetsRoot(project).resolve(RECOVERED_DIR), sanitizeSegment(assetName));
|
||||
}
|
||||
final Path siblingParent = assetRoot.getParent() == null ? PackerWorkspacePaths.assetsRoot(project) : assetRoot.getParent();
|
||||
return nextAvailablePath(siblingParent, assetRoot.getFileName().toString() + "-relocated");
|
||||
}
|
||||
|
||||
private Path nextAvailablePath(Path parent, String baseName) {
|
||||
final Path normalizedParent = parent.toAbsolutePath().normalize();
|
||||
Path candidate = normalizedParent.resolve(baseName);
|
||||
int index = 2;
|
||||
while (Files.exists(candidate)) {
|
||||
candidate = normalizedParent.resolve(baseName + "-" + index);
|
||||
index += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private String sanitizeSegment(String value) {
|
||||
final String sanitized = value == null
|
||||
? "asset"
|
||||
: value.trim()
|
||||
.replaceAll("[^A-Za-z0-9._-]+", "-")
|
||||
.replaceAll("-{2,}", "-")
|
||||
.replaceAll("^[.-]+|[.-]+$", "");
|
||||
return sanitized.isBlank() ? "asset" : sanitized;
|
||||
}
|
||||
|
||||
private void moveAssetRoot(Path sourceRoot, Path targetRoot) {
|
||||
if (sourceRoot.equals(targetRoot)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(targetRoot.getParent());
|
||||
Files.move(sourceRoot, targetRoot);
|
||||
} catch (IOException exception) {
|
||||
throw new UncheckedIOException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteRecursively(Path root) {
|
||||
if (!Files.exists(root)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.walk(root)) {
|
||||
stream.sorted(Comparator.reverseOrder()).forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException exception) {
|
||||
throw new UncheckedIOException(exception);
|
||||
}
|
||||
});
|
||||
} catch (IOException exception) {
|
||||
throw new UncheckedIOException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Path requireTarget(PackerMutationPreview preview) {
|
||||
if (preview.targetAssetRoot() == null) {
|
||||
throw new PackerMutationException("Mutation preview does not define a target asset root");
|
||||
}
|
||||
return preview.targetAssetRoot();
|
||||
}
|
||||
|
||||
private void emit(
|
||||
PackerProjectContext project,
|
||||
String operationId,
|
||||
long sequence,
|
||||
PackerEventKind kind,
|
||||
String summary,
|
||||
List<String> affectedAssets) {
|
||||
eventSink.publish(new PackerEvent(
|
||||
project.projectId(),
|
||||
operationId,
|
||||
sequence,
|
||||
kind,
|
||||
Instant.now(),
|
||||
summary,
|
||||
null,
|
||||
affectedAssets));
|
||||
}
|
||||
|
||||
private List<String> affectedAssets(PackerMutationPreview preview) {
|
||||
return List.of(preview.request().assetReference());
|
||||
}
|
||||
|
||||
private String rootCauseMessage(Throwable throwable) {
|
||||
Throwable current = throwable;
|
||||
while (current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
return current.getMessage() == null || current.getMessage().isBlank()
|
||||
? current.getClass().getSimpleName()
|
||||
: current.getMessage();
|
||||
}
|
||||
|
||||
private record ResolvedMutationContext(
|
||||
PackerProjectContext project,
|
||||
PackerMutationRequest request,
|
||||
PackerAssetDetails assetDetails,
|
||||
List<String> initialBlockers) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package p.packer.mutations;
|
||||
|
||||
public final class PackerMutationException extends RuntimeException {
|
||||
public PackerMutationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public PackerMutationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package p.packer.mutations;
|
||||
|
||||
import p.packer.api.PackerProjectContext;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class PackerProjectWriteCoordinator {
|
||||
private final ConcurrentMap<Path, ReentrantLock> locks = new ConcurrentHashMap<>();
|
||||
|
||||
public <T> T withWriteLock(PackerProjectContext project, Supplier<T> work) {
|
||||
final Path key = Objects.requireNonNull(project, "project").rootPath().toAbsolutePath().normalize();
|
||||
final ReentrantLock lock = locks.computeIfAbsent(key, ignored -> new ReentrantLock());
|
||||
lock.lock();
|
||||
try {
|
||||
return Objects.requireNonNull(work, "work").get();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,179 @@
|
||||
package p.packer.mutations;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import p.packer.api.PackerProjectContext;
|
||||
import p.packer.api.events.PackerEvent;
|
||||
import p.packer.api.events.PackerEventKind;
|
||||
import p.packer.api.mutations.PackerMutationPreview;
|
||||
import p.packer.api.mutations.PackerMutationRequest;
|
||||
import p.packer.api.mutations.PackerMutationType;
|
||||
import p.packer.declarations.PackerAssetDetailsService;
|
||||
import p.packer.foundation.PackerWorkspaceFoundation;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class FileSystemPackerMutationServiceTest {
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void previewAndApplyQuarantineForManagedAssetShowsStructuredImpact() throws Exception {
|
||||
final Path projectRoot = createManagedAssetProject();
|
||||
final List<PackerEvent> events = new CopyOnWriteArrayList<>();
|
||||
final FileSystemPackerMutationService service = service(events);
|
||||
final PackerProjectContext project = project(projectRoot);
|
||||
|
||||
final PackerMutationPreview preview = service.preview(new PackerMutationRequest(
|
||||
project,
|
||||
PackerMutationType.QUARANTINE_ASSET,
|
||||
"1",
|
||||
null));
|
||||
|
||||
assertTrue(preview.canApply());
|
||||
assertFalse(preview.highRisk());
|
||||
assertNotNull(preview.targetAssetRoot());
|
||||
assertEquals(1, preview.proposedActions().stream().filter(action -> action.operationClass() == p.packer.api.PackerOperationClass.REGISTRY_MUTATION).count());
|
||||
assertEquals(1, preview.proposedActions().stream().filter(action -> action.operationClass() == p.packer.api.PackerOperationClass.WORKSPACE_MUTATION).count());
|
||||
|
||||
service.apply(preview);
|
||||
|
||||
assertFalse(Files.exists(projectRoot.resolve("assets/ui/atlas")));
|
||||
assertTrue(Files.isDirectory(preview.targetAssetRoot()));
|
||||
final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json"));
|
||||
assertFalse(registryJson.contains("\"root\" : \"ui/atlas\""));
|
||||
assertEquals(List.of(PackerEventKind.PREVIEW_READY, PackerEventKind.ACTION_APPLIED), events.stream().map(PackerEvent::kind).toList());
|
||||
assertEquals(preview.operationId(), events.getFirst().operationId());
|
||||
assertEquals(preview.operationId(), events.get(1).operationId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyRelocatePreservesIdentityAndUpdatesRegistryRoot() throws Exception {
|
||||
final Path projectRoot = createManagedAssetProject();
|
||||
final FileSystemPackerMutationService service = service(new CopyOnWriteArrayList<>());
|
||||
final PackerProjectContext project = project(projectRoot);
|
||||
|
||||
final PackerMutationPreview preview = service.preview(new PackerMutationRequest(
|
||||
project,
|
||||
PackerMutationType.RELOCATE_ASSET,
|
||||
"1",
|
||||
null));
|
||||
|
||||
assertTrue(preview.canApply());
|
||||
assertTrue(preview.highRisk());
|
||||
service.apply(preview);
|
||||
|
||||
assertFalse(Files.exists(projectRoot.resolve("assets/ui/atlas")));
|
||||
assertTrue(Files.isDirectory(preview.targetAssetRoot()));
|
||||
final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json"));
|
||||
assertTrue(registryJson.contains("\"asset_id\" : 1"));
|
||||
assertTrue(registryJson.contains("\"asset_uuid\" : \"uuid-1\""));
|
||||
assertTrue(registryJson.contains(relativeAssetRoot(project, preview.targetAssetRoot())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewRegisterBlocksInvalidAssetDeclarations() throws Exception {
|
||||
final Path projectRoot = tempDir.resolve("invalid");
|
||||
final Path assetRoot = projectRoot.resolve("assets/ui/broken");
|
||||
Files.createDirectories(assetRoot);
|
||||
Files.writeString(assetRoot.resolve("asset.json"), """
|
||||
{
|
||||
"schema_version": 1,
|
||||
"type": "image_bank"
|
||||
}
|
||||
""");
|
||||
|
||||
final FileSystemPackerMutationService service = service(new CopyOnWriteArrayList<>());
|
||||
|
||||
final PackerMutationPreview preview = service.preview(new PackerMutationRequest(
|
||||
project(projectRoot),
|
||||
PackerMutationType.REGISTER_ASSET,
|
||||
"ui/broken",
|
||||
null));
|
||||
|
||||
assertFalse(preview.canApply());
|
||||
assertTrue(preview.blockers().stream().anyMatch(message -> message.contains("valid before registration")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emitsFailureLifecycleWhenApplyFails() throws Exception {
|
||||
final Path projectRoot = createManagedAssetProject();
|
||||
final List<PackerEvent> events = new CopyOnWriteArrayList<>();
|
||||
final FileSystemPackerMutationService service = service(events);
|
||||
final PackerMutationPreview preview = service.preview(new PackerMutationRequest(
|
||||
project(projectRoot),
|
||||
PackerMutationType.RELOCATE_ASSET,
|
||||
"1",
|
||||
null));
|
||||
|
||||
deleteRecursively(projectRoot.resolve("assets/ui/atlas"));
|
||||
|
||||
assertThrows(UncheckedIOException.class, () -> service.apply(preview));
|
||||
assertEquals(PackerEventKind.ACTION_FAILED, events.getLast().kind());
|
||||
assertEquals(preview.operationId(), events.getLast().operationId());
|
||||
}
|
||||
|
||||
private FileSystemPackerMutationService service(List<PackerEvent> events) {
|
||||
return new FileSystemPackerMutationService(
|
||||
new PackerWorkspaceFoundation(),
|
||||
new PackerAssetDetailsService(),
|
||||
new PackerProjectWriteCoordinator(),
|
||||
events::add);
|
||||
}
|
||||
|
||||
private Path createManagedAssetProject() throws Exception {
|
||||
final Path projectRoot = tempDir.resolve("main");
|
||||
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
|
||||
Files.createDirectories(assetRoot);
|
||||
Files.createDirectories(projectRoot.resolve("assets/.prometeu"));
|
||||
Files.writeString(assetRoot.resolve("asset.json"), """
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "ui_atlas",
|
||||
"type": "image_bank",
|
||||
"preload": { "enabled": true }
|
||||
}
|
||||
""");
|
||||
Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """
|
||||
{
|
||||
"schema_version": 1,
|
||||
"next_asset_id": 2,
|
||||
"assets": [
|
||||
{
|
||||
"asset_id": 1,
|
||||
"asset_uuid": "uuid-1",
|
||||
"root": "ui/atlas"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
private PackerProjectContext project(Path root) {
|
||||
return new PackerProjectContext("main", root);
|
||||
}
|
||||
|
||||
private String relativeAssetRoot(PackerProjectContext project, Path assetRoot) {
|
||||
return project.rootPath().resolve("assets").toAbsolutePath().normalize()
|
||||
.relativize(assetRoot.toAbsolutePath().normalize())
|
||||
.toString()
|
||||
.replace('\\', '/');
|
||||
}
|
||||
|
||||
private void deleteRecursively(Path root) throws IOException {
|
||||
try (var stream = Files.walk(root)) {
|
||||
for (Path path : stream.sorted(Comparator.reverseOrder()).toList()) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package p.packer.mutations;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import p.packer.api.PackerProjectContext;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class PackerProjectWriteCoordinatorTest {
|
||||
@Test
|
||||
void serializesWritesWithinTheSameProject() throws Exception {
|
||||
final PackerProjectWriteCoordinator coordinator = new PackerProjectWriteCoordinator();
|
||||
final PackerProjectContext project = new PackerProjectContext("main", Path.of("/tmp/main"));
|
||||
final AtomicInteger concurrentWriters = new AtomicInteger();
|
||||
final AtomicInteger maxConcurrentWriters = new AtomicInteger();
|
||||
final CountDownLatch firstEntered = new CountDownLatch(1);
|
||||
final CountDownLatch releaseFirst = new CountDownLatch(1);
|
||||
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
|
||||
final Future<?> first = executor.submit(() -> coordinator.withWriteLock(project, () -> {
|
||||
final int current = concurrentWriters.incrementAndGet();
|
||||
maxConcurrentWriters.accumulateAndGet(current, Math::max);
|
||||
firstEntered.countDown();
|
||||
await(releaseFirst);
|
||||
concurrentWriters.decrementAndGet();
|
||||
return null;
|
||||
}));
|
||||
|
||||
assertTrue(firstEntered.await(2, TimeUnit.SECONDS));
|
||||
|
||||
final Future<?> second = executor.submit(() -> coordinator.withWriteLock(project, () -> {
|
||||
final int current = concurrentWriters.incrementAndGet();
|
||||
maxConcurrentWriters.accumulateAndGet(current, Math::max);
|
||||
concurrentWriters.decrementAndGet();
|
||||
return null;
|
||||
}));
|
||||
|
||||
Thread.sleep(100L);
|
||||
assertFalse(second.isDone());
|
||||
releaseFirst.countDown();
|
||||
first.get(2, TimeUnit.SECONDS);
|
||||
second.get(2, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
assertEquals(1, maxConcurrentWriters.get());
|
||||
}
|
||||
|
||||
private void await(CountDownLatch latch) {
|
||||
try {
|
||||
latch.await(2, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(interruptedException);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -56,17 +56,31 @@ public final class AssetWorkspace implements Workspace {
|
||||
private String searchQuery = "";
|
||||
|
||||
public AssetWorkspace(ProjectReference projectReference) {
|
||||
this(projectReference, new PackerBackedAssetWorkspaceService(), new FileSystemAssetWorkspaceMutationService());
|
||||
this(
|
||||
projectReference,
|
||||
new PackerBackedAssetWorkspaceService(),
|
||||
defaultWorkspaceBus(),
|
||||
null);
|
||||
}
|
||||
|
||||
public AssetWorkspace(
|
||||
ProjectReference projectReference,
|
||||
AssetWorkspaceService assetWorkspaceService,
|
||||
AssetWorkspaceMutationService mutationService) {
|
||||
this(projectReference, assetWorkspaceService, defaultWorkspaceBus(), mutationService);
|
||||
}
|
||||
|
||||
private AssetWorkspace(
|
||||
ProjectReference projectReference,
|
||||
AssetWorkspaceService assetWorkspaceService,
|
||||
StudioWorkspaceEventBus workspaceBus,
|
||||
AssetWorkspaceMutationService mutationService) {
|
||||
this.projectReference = Objects.requireNonNull(projectReference, "projectReference");
|
||||
this.assetWorkspaceService = Objects.requireNonNull(assetWorkspaceService, "assetWorkspaceService");
|
||||
this.mutationService = Objects.requireNonNull(mutationService, "mutationService");
|
||||
this.workspaceBus = new StudioWorkspaceEventBus(WorkspaceId.ASSETS, Container.events());
|
||||
this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus");
|
||||
this.mutationService = mutationService == null
|
||||
? new PackerBackedAssetWorkspaceMutationService(this.workspaceBus)
|
||||
: Objects.requireNonNull(mutationService, "mutationService");
|
||||
|
||||
root.getStyleClass().add("assets-workspace");
|
||||
root.setCenter(buildLayout());
|
||||
@ -98,6 +112,10 @@ public final class AssetWorkspace implements Workspace {
|
||||
return state;
|
||||
}
|
||||
|
||||
private static StudioWorkspaceEventBus defaultWorkspaceBus() {
|
||||
return new StudioWorkspaceEventBus(WorkspaceId.ASSETS, Container.events());
|
||||
}
|
||||
|
||||
private VBox buildLayout() {
|
||||
inlineProgressLabel.getStyleClass().add("assets-workspace-inline-progress-label");
|
||||
inlineProgressLabel.setText(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE));
|
||||
@ -699,7 +717,6 @@ public final class AssetWorkspace implements Workspace {
|
||||
final AssetWorkspaceMutationPreview preview = mutationService.preview(projectReference, selectedAsset, action);
|
||||
stagedMutationPreview = preview;
|
||||
appendLog("Preview ready for " + actionLabel(action) + ".");
|
||||
workspaceBus.publish(new StudioAssetsMutationPreviewReadyEvent(projectReference, action, 1));
|
||||
renderState();
|
||||
}
|
||||
|
||||
@ -802,13 +819,11 @@ public final class AssetWorkspace implements Workspace {
|
||||
try {
|
||||
mutationService.apply(projectReference, preview);
|
||||
appendLog("Applied " + actionLabel(preview.action()) + ".");
|
||||
workspaceBus.publish(new StudioAssetsMutationAppliedEvent(projectReference, preview.action(), 1));
|
||||
stagedMutationPreview = null;
|
||||
refresh();
|
||||
} catch (RuntimeException runtimeException) {
|
||||
final String message = rootCauseMessage(runtimeException);
|
||||
appendLog("Mutation failed: " + message);
|
||||
workspaceBus.publish(new StudioAssetsMutationFailedEvent(projectReference, preview.action(), message));
|
||||
stagedMutationPreview = preview;
|
||||
renderState();
|
||||
}
|
||||
|
||||
@ -0,0 +1,170 @@
|
||||
package p.studio.workspaces.assets;
|
||||
|
||||
import p.packer.api.PackerOperationClass;
|
||||
import p.packer.api.PackerProjectContext;
|
||||
import p.packer.api.events.PackerEvent;
|
||||
import p.packer.api.events.PackerEventKind;
|
||||
import p.packer.api.mutations.PackerMutationPreview;
|
||||
import p.packer.api.mutations.PackerMutationRequest;
|
||||
import p.packer.api.mutations.PackerMutationType;
|
||||
import p.packer.api.mutations.PackerProposedAction;
|
||||
import p.packer.declarations.PackerAssetDetailsService;
|
||||
import p.packer.foundation.PackerWorkspaceFoundation;
|
||||
import p.packer.mutations.FileSystemPackerMutationService;
|
||||
import p.packer.mutations.PackerProjectWriteCoordinator;
|
||||
import p.studio.events.StudioAssetsMutationAppliedEvent;
|
||||
import p.studio.events.StudioAssetsMutationFailedEvent;
|
||||
import p.studio.events.StudioAssetsMutationPreviewReadyEvent;
|
||||
import p.studio.events.StudioWorkspaceEventBus;
|
||||
import p.studio.projects.ProjectReference;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
public final class PackerBackedAssetWorkspaceMutationService implements AssetWorkspaceMutationService {
|
||||
private final FileSystemPackerMutationService packerMutationService;
|
||||
private final StudioWorkspaceEventBus eventBus;
|
||||
private final ConcurrentMap<AssetWorkspaceMutationPreview, OperationSession> previewSessions = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, OperationSession> operationSessions = new ConcurrentHashMap<>();
|
||||
|
||||
public PackerBackedAssetWorkspaceMutationService(StudioWorkspaceEventBus eventBus) {
|
||||
this(eventBus, new PackerWorkspaceFoundation(), new PackerAssetDetailsService(), new PackerProjectWriteCoordinator());
|
||||
}
|
||||
|
||||
PackerBackedAssetWorkspaceMutationService(
|
||||
StudioWorkspaceEventBus eventBus,
|
||||
PackerWorkspaceFoundation workspaceFoundation,
|
||||
PackerAssetDetailsService detailsService,
|
||||
PackerProjectWriteCoordinator writeCoordinator) {
|
||||
this.eventBus = Objects.requireNonNull(eventBus, "eventBus");
|
||||
this.packerMutationService = new FileSystemPackerMutationService(
|
||||
Objects.requireNonNull(workspaceFoundation, "workspaceFoundation"),
|
||||
Objects.requireNonNull(detailsService, "detailsService"),
|
||||
Objects.requireNonNull(writeCoordinator, "writeCoordinator"),
|
||||
this::forwardLifecycleEvent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AssetWorkspaceMutationPreview preview(ProjectReference projectReference, AssetWorkspaceAssetSummary asset, AssetWorkspaceAction action) {
|
||||
Objects.requireNonNull(projectReference, "projectReference");
|
||||
Objects.requireNonNull(asset, "asset");
|
||||
final PackerMutationPreview packerPreview = packerMutationService.preview(
|
||||
new PackerMutationRequest(
|
||||
project(projectReference),
|
||||
mutationType(action),
|
||||
assetReference(projectReference, asset),
|
||||
null));
|
||||
final AssetWorkspaceMutationPreview studioPreview = mapPreview(action, asset, packerPreview);
|
||||
final OperationSession session = new OperationSession(projectReference, action, studioPreview, packerPreview);
|
||||
previewSessions.put(studioPreview, session);
|
||||
operationSessions.put(packerPreview.operationId(), session);
|
||||
eventBus.publish(new StudioAssetsMutationPreviewReadyEvent(projectReference, action, 1));
|
||||
return studioPreview;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(ProjectReference projectReference, AssetWorkspaceMutationPreview preview) {
|
||||
Objects.requireNonNull(projectReference, "projectReference");
|
||||
final OperationSession session = previewSessions.get(Objects.requireNonNull(preview, "preview"));
|
||||
if (session == null) {
|
||||
throw new IllegalStateException("Mutation preview is not backed by a packer preview.");
|
||||
}
|
||||
try {
|
||||
packerMutationService.apply(session.packerPreview());
|
||||
} catch (RuntimeException exception) {
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
void forwardLifecycleEvent(PackerEvent event) {
|
||||
final OperationSession session = operationSessions.get(Objects.requireNonNull(event, "event").operationId());
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
if (event.kind() == PackerEventKind.ACTION_APPLIED) {
|
||||
previewSessions.remove(session.studioPreview());
|
||||
operationSessions.remove(event.operationId());
|
||||
eventBus.publish(new StudioAssetsMutationAppliedEvent(
|
||||
session.projectReference(),
|
||||
session.action(),
|
||||
affectedAssetCount(event)));
|
||||
return;
|
||||
}
|
||||
if (event.kind() == PackerEventKind.ACTION_FAILED) {
|
||||
eventBus.publish(new StudioAssetsMutationFailedEvent(
|
||||
session.projectReference(),
|
||||
session.action(),
|
||||
event.summary()));
|
||||
}
|
||||
}
|
||||
|
||||
private AssetWorkspaceMutationPreview mapPreview(
|
||||
AssetWorkspaceAction action,
|
||||
AssetWorkspaceAssetSummary asset,
|
||||
PackerMutationPreview preview) {
|
||||
final List<AssetWorkspaceMutationChange> changes = preview.proposedActions().stream()
|
||||
.map(this::mapChange)
|
||||
.toList();
|
||||
return new AssetWorkspaceMutationPreview(
|
||||
action,
|
||||
asset,
|
||||
preview.blockers(),
|
||||
preview.warnings(),
|
||||
preview.safeFixes(),
|
||||
changes,
|
||||
preview.highRisk(),
|
||||
preview.targetAssetRoot());
|
||||
}
|
||||
|
||||
private AssetWorkspaceMutationChange mapChange(PackerProposedAction action) {
|
||||
return new AssetWorkspaceMutationChange(
|
||||
scope(action.operationClass()),
|
||||
action.verb(),
|
||||
action.target());
|
||||
}
|
||||
|
||||
private AssetWorkspaceMutationChangeScope scope(PackerOperationClass operationClass) {
|
||||
return switch (operationClass) {
|
||||
case REGISTRY_MUTATION -> AssetWorkspaceMutationChangeScope.REGISTRY;
|
||||
case WORKSPACE_MUTATION, READ_ONLY -> AssetWorkspaceMutationChangeScope.WORKSPACE;
|
||||
};
|
||||
}
|
||||
|
||||
private PackerMutationType mutationType(AssetWorkspaceAction action) {
|
||||
return switch (Objects.requireNonNull(action, "action")) {
|
||||
case ADOPT -> PackerMutationType.ADOPT_ASSET;
|
||||
case REGISTER -> PackerMutationType.REGISTER_ASSET;
|
||||
case QUARANTINE -> PackerMutationType.QUARANTINE_ASSET;
|
||||
case RELOCATE -> PackerMutationType.RELOCATE_ASSET;
|
||||
case FORGET -> PackerMutationType.FORGET_ASSET;
|
||||
case REMOVE -> PackerMutationType.REMOVE_ASSET;
|
||||
case DOCTOR, BUILD -> throw new IllegalArgumentException("Action is not supported by the staged mutation flow: " + action);
|
||||
};
|
||||
}
|
||||
|
||||
private PackerProjectContext project(ProjectReference projectReference) {
|
||||
return new PackerProjectContext(projectReference.name(), projectReference.rootPath());
|
||||
}
|
||||
|
||||
private String assetReference(ProjectReference projectReference, AssetWorkspaceAssetSummary asset) {
|
||||
if (asset.assetId() != null) {
|
||||
return Integer.toString(asset.assetId());
|
||||
}
|
||||
final Path assetsRoot = projectReference.rootPath().resolve("assets").toAbsolutePath().normalize();
|
||||
return assetsRoot.relativize(asset.assetRoot().toAbsolutePath().normalize()).toString().replace('\\', '/');
|
||||
}
|
||||
|
||||
private int affectedAssetCount(PackerEvent event) {
|
||||
return event.affectedAssets().isEmpty() ? 1 : event.affectedAssets().size();
|
||||
}
|
||||
|
||||
private record OperationSession(
|
||||
ProjectReference projectReference,
|
||||
AssetWorkspaceAction action,
|
||||
AssetWorkspaceMutationPreview studioPreview,
|
||||
PackerMutationPreview packerPreview) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,148 @@
|
||||
package p.studio.workspaces.assets;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import p.packer.declarations.PackerAssetDetailsService;
|
||||
import p.packer.foundation.PackerWorkspaceFoundation;
|
||||
import p.packer.mutations.PackerProjectWriteCoordinator;
|
||||
import p.studio.events.StudioAssetsMutationAppliedEvent;
|
||||
import p.studio.events.StudioAssetsMutationFailedEvent;
|
||||
import p.studio.events.StudioAssetsMutationPreviewReadyEvent;
|
||||
import p.studio.events.StudioEventBus;
|
||||
import p.studio.events.StudioWorkspaceEventBus;
|
||||
import p.studio.projects.ProjectReference;
|
||||
import p.studio.workspaces.WorkspaceId;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class PackerBackedAssetWorkspaceMutationServiceTest {
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void previewMapsPackerImpactAndPublishesPreviewReadyEvent() throws Exception {
|
||||
final Path projectRoot = createManagedAssetProject();
|
||||
final StudioEventBus globalBus = new StudioEventBus();
|
||||
final List<StudioAssetsMutationPreviewReadyEvent> previewEvents = new ArrayList<>();
|
||||
globalBus.subscribe(StudioAssetsMutationPreviewReadyEvent.class, previewEvents::add);
|
||||
final PackerBackedAssetWorkspaceMutationService service = service(globalBus);
|
||||
|
||||
final AssetWorkspaceMutationPreview preview = service.preview(
|
||||
project("Main", projectRoot),
|
||||
managedAsset(projectRoot),
|
||||
AssetWorkspaceAction.QUARANTINE);
|
||||
|
||||
assertEquals(1, previewEvents.size());
|
||||
assertTrue(preview.canApply());
|
||||
assertNotNull(preview.targetAssetRoot());
|
||||
assertEquals(1, preview.changes().stream().filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY).count());
|
||||
assertEquals(1, preview.changes().stream().filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyPublishesAppliedEventFromPackerLifecycle() throws Exception {
|
||||
final Path projectRoot = createManagedAssetProject();
|
||||
final StudioEventBus globalBus = new StudioEventBus();
|
||||
final List<StudioAssetsMutationAppliedEvent> appliedEvents = new ArrayList<>();
|
||||
globalBus.subscribe(StudioAssetsMutationAppliedEvent.class, appliedEvents::add);
|
||||
final PackerBackedAssetWorkspaceMutationService service = service(globalBus);
|
||||
final AssetWorkspaceMutationPreview preview = service.preview(
|
||||
project("Main", projectRoot),
|
||||
managedAsset(projectRoot),
|
||||
AssetWorkspaceAction.RELOCATE);
|
||||
|
||||
service.apply(project("Main", projectRoot), preview);
|
||||
|
||||
assertEquals(1, appliedEvents.size());
|
||||
assertEquals(AssetWorkspaceAction.RELOCATE, appliedEvents.getFirst().action());
|
||||
assertTrue(Files.isDirectory(preview.targetAssetRoot()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyFailurePublishesFailedEventFromPackerLifecycle() throws Exception {
|
||||
final Path projectRoot = createManagedAssetProject();
|
||||
final StudioEventBus globalBus = new StudioEventBus();
|
||||
final List<StudioAssetsMutationFailedEvent> failedEvents = new ArrayList<>();
|
||||
globalBus.subscribe(StudioAssetsMutationFailedEvent.class, failedEvents::add);
|
||||
final PackerBackedAssetWorkspaceMutationService service = service(globalBus);
|
||||
final AssetWorkspaceMutationPreview preview = service.preview(
|
||||
project("Main", projectRoot),
|
||||
managedAsset(projectRoot),
|
||||
AssetWorkspaceAction.RELOCATE);
|
||||
|
||||
deleteRecursively(projectRoot.resolve("assets/ui/atlas"));
|
||||
|
||||
assertThrows(RuntimeException.class, () -> service.apply(project("Main", projectRoot), preview));
|
||||
assertEquals(1, failedEvents.size());
|
||||
assertEquals(AssetWorkspaceAction.RELOCATE, failedEvents.getFirst().action());
|
||||
assertFalse(failedEvents.getFirst().message().isBlank());
|
||||
}
|
||||
|
||||
private PackerBackedAssetWorkspaceMutationService service(StudioEventBus globalBus) {
|
||||
return new PackerBackedAssetWorkspaceMutationService(
|
||||
new StudioWorkspaceEventBus(WorkspaceId.ASSETS, globalBus),
|
||||
new PackerWorkspaceFoundation(),
|
||||
new PackerAssetDetailsService(),
|
||||
new PackerProjectWriteCoordinator());
|
||||
}
|
||||
|
||||
private Path createManagedAssetProject() throws Exception {
|
||||
final Path projectRoot = tempDir.resolve("main");
|
||||
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
|
||||
Files.createDirectories(assetRoot);
|
||||
Files.createDirectories(projectRoot.resolve("assets/.prometeu"));
|
||||
Files.writeString(assetRoot.resolve("asset.json"), """
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "ui_atlas",
|
||||
"type": "image_bank",
|
||||
"preload": { "enabled": true }
|
||||
}
|
||||
""");
|
||||
Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """
|
||||
{
|
||||
"schema_version": 1,
|
||||
"next_asset_id": 2,
|
||||
"assets": [
|
||||
{
|
||||
"asset_id": 1,
|
||||
"asset_uuid": "uuid-1",
|
||||
"root": "ui/atlas"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
private AssetWorkspaceAssetSummary managedAsset(Path projectRoot) {
|
||||
return new AssetWorkspaceAssetSummary(
|
||||
new AssetWorkspaceSelectionKey.ManagedAsset(1),
|
||||
"ui_atlas",
|
||||
AssetWorkspaceAssetState.MANAGED,
|
||||
1,
|
||||
"image_bank",
|
||||
projectRoot.resolve("assets/ui/atlas"),
|
||||
true,
|
||||
false);
|
||||
}
|
||||
|
||||
private ProjectReference project(String name, Path root) {
|
||||
return new ProjectReference(name, "1.0.0", "pbs", 1, root);
|
||||
}
|
||||
|
||||
private void deleteRecursively(Path root) throws IOException {
|
||||
try (var stream = Files.walk(root)) {
|
||||
for (Path path : stream.sorted(Comparator.reverseOrder()).toList()) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user