Skip to content

Commit 5dd23db

Browse files
committed
implement CMA
1 parent 35e4462 commit 5dd23db

File tree

4 files changed

+303
-0
lines changed

4 files changed

+303
-0
lines changed

pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@
7474
<version>5.1.0</version>
7575
<scope>provided</scope>
7676
</dependency>
77+
<dependency>
78+
<groupId>com.atlassian</groupId>
79+
<artifactId>atlassian-app-cloud-migration-listener</artifactId>
80+
<version>1.8.7</version>
81+
<scope>provided</scope>
82+
</dependency>
7783
<dependency>
7884
<groupId>junit</groupId>
7985
<artifactId>junit</artifactId>
@@ -224,6 +230,7 @@
224230
com.atlassian.mywork.*;resolution:="optional",
225231
com.google.gson.*;resolution:="optional",
226232
org.jetbrains.annotations.*;resolution:="optional",
233+
com.atlassian.migration.app.*;resolution:="optional",
227234
com.sun.mail.imap,
228235
com.sun.mail.smtp,
229236
com.sun.mail.pop3,
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package com.baloise.confluence.digitalsignature.migration;
2+
3+
import static com.atlassian.confluence.setup.bandana.ConfluenceBandanaContext.GLOBAL_CONTEXT;
4+
5+
import java.io.IOException;
6+
import java.io.OutputStream;
7+
import java.io.OutputStreamWriter;
8+
import java.nio.charset.StandardCharsets;
9+
import java.util.Map;
10+
import java.util.Set;
11+
import java.util.UUID;
12+
import java.util.zip.GZIPOutputStream;
13+
14+
import org.slf4j.Logger;
15+
import org.slf4j.LoggerFactory;
16+
17+
import com.atlassian.bandana.BandanaManager;
18+
import com.atlassian.migration.app.AccessScope;
19+
import com.atlassian.migration.app.confluence.ConfluenceAppCloudMigrationListenerV1;
20+
import com.atlassian.migration.app.gateway.AppCloudForgeMigrationGateway;
21+
import com.atlassian.migration.app.gateway.MigrationDetailsV1;
22+
import com.atlassian.migration.app.listener.DiscoverableForgeListener;
23+
import com.atlassian.plugin.spring.scanner.annotation.component.ConfluenceComponent;
24+
import com.atlassian.plugin.spring.scanner.annotation.export.ExportAsService;
25+
import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
26+
import com.baloise.confluence.digitalsignature.Signature2;
27+
import com.google.gson.JsonObject;
28+
29+
/**
30+
* Cloud Migration Assistant (CMA) listener for the Digital Signature plugin.
31+
*
32+
* <p>Exports all signature contracts stored in Bandana as a JSONL.gz payload so
33+
* the Forge cloud app can import them into its SQL storage during migration.
34+
* Also provides the macro key mapping so migrated macros render correctly in Cloud.
35+
*
36+
* <p>Corresponds to backlog item i0051.
37+
*/
38+
@ConfluenceComponent
39+
@ExportAsService({DiscoverableForgeListener.class, ConfluenceAppCloudMigrationListenerV1.class})
40+
public class DigitalSignatureMigrationListener implements DiscoverableForgeListener, ConfluenceAppCloudMigrationListenerV1 {
41+
42+
private static final Logger log = LoggerFactory.getLogger(DigitalSignatureMigrationListener.class);
43+
44+
static final String FORGE_APP_ID = "bab5617e-dc42-4ca8-ad38-947c826fe58c";
45+
static final String SERVER_APP_KEY = "com.baloise.confluence.digital-signature";
46+
// Server macro key (macro name in atlassian-plugin.xml) → Forge macro key
47+
static final String SERVER_MACRO_KEY = "digital-signature";
48+
static final String FORGE_MACRO_KEY = "digital-signature-confluence-cloud-culmat";
49+
50+
private final BandanaManager bandanaManager;
51+
52+
public DigitalSignatureMigrationListener(@ComponentImport BandanaManager bandanaManager) {
53+
this.bandanaManager = bandanaManager;
54+
}
55+
56+
@Override
57+
public UUID getForgeAppId() {
58+
return UUID.fromString(FORGE_APP_ID);
59+
}
60+
61+
@Override
62+
public String getForgeEnvironmentName() {
63+
return "PRODUCTION";
64+
}
65+
66+
@Override
67+
public String getCloudAppKey() {
68+
return "com.baloise.confluence.digital-signature";
69+
}
70+
71+
@Override
72+
public String getServerAppKey() {
73+
return SERVER_APP_KEY;
74+
}
75+
76+
@Override
77+
public Map<String, String> getServerToForgeMacroMapping() {
78+
return Map.of(SERVER_MACRO_KEY, FORGE_MACRO_KEY);
79+
}
80+
81+
@Override
82+
public Set<AccessScope> getDataAccessScopes() {
83+
return Set.of(
84+
AccessScope.APP_DATA_OTHER,
85+
AccessScope.PRODUCT_DATA_OTHER,
86+
AccessScope.MIGRATION_TRACING_IDENTITY,
87+
AccessScope.MIGRATION_TRACING_PRODUCT
88+
);
89+
}
90+
91+
/**
92+
* Exports all signature contracts from Bandana as a JSONL.gz stream.
93+
*
94+
* <p>Each line in the output is a JSON object with fields:
95+
* {@code hash}, {@code pageId}, {@code title}, {@code body}, {@code signatures}.
96+
* The {@code signatures} map uses server usernames as keys; the Forge importer
97+
* resolves them to Cloud account IDs via the CMA Mappings API.
98+
*/
99+
@Override
100+
public void onStartAppMigration(AppCloudForgeMigrationGateway gateway, MigrationDetailsV1 migrationDetails) {
101+
int scanned = 0;
102+
int exported = 0;
103+
int totalSignatures = 0;
104+
105+
try (OutputStream raw = gateway.createAppData("signatures");
106+
GZIPOutputStream gzip = new GZIPOutputStream(raw);
107+
OutputStreamWriter writer = new OutputStreamWriter(gzip, StandardCharsets.UTF_8)) {
108+
109+
Iterable<String> keys = bandanaManager.getKeys(GLOBAL_CONTEXT);
110+
if (keys != null) {
111+
for (String key : keys) {
112+
if (!key.startsWith("signature.")) {
113+
continue;
114+
}
115+
scanned++;
116+
Signature2 sig = Signature2.fromBandana(bandanaManager, key);
117+
if (sig == null) {
118+
log.warn("Skipping null/corrupt Bandana entry: {}", key);
119+
continue;
120+
}
121+
writer.write(toJsonLine(sig));
122+
writer.write('\n');
123+
exported++;
124+
totalSignatures += sig.getSignatures().size();
125+
}
126+
}
127+
} catch (IOException e) {
128+
log.error("Failed to export signature data for migration", e);
129+
throw new RuntimeException("Migration export failed", e);
130+
}
131+
132+
log.info("Migration export complete: {} keys scanned, {} contracts exported, {} signatures exported",
133+
scanned, exported, totalSignatures);
134+
gateway.completeExport();
135+
}
136+
137+
/**
138+
* Serializes one {@link Signature2} into a single JSONL line (no trailing newline).
139+
*
140+
* <p>Omits {@code missingSignatures} and {@code notify} — the Forge app
141+
* reconstructs required signers from the current macro config at render time.
142+
*/
143+
static String toJsonLine(Signature2 sig) {
144+
JsonObject obj = new JsonObject();
145+
obj.addProperty("hash", sig.getHash());
146+
obj.addProperty("pageId", sig.getPageId());
147+
obj.addProperty("title", sig.getTitle());
148+
obj.addProperty("body", sig.getBody());
149+
obj.add("signatures", Signature2.GSON.toJsonTree(sig.getSignatures()));
150+
return Signature2.GSON.toJson(obj);
151+
}
152+
}

src/main/resources/atlassian-plugin.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,5 @@
9999
<package>com.baloise.confluence.digitalsignature.rest</package>
100100
</rest>
101101

102+
102103
</atlassian-plugin>
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package com.baloise.confluence.digitalsignature.migration;
2+
3+
import static com.atlassian.confluence.setup.bandana.ConfluenceBandanaContext.GLOBAL_CONTEXT;
4+
import static org.junit.jupiter.api.Assertions.*;
5+
import static org.mockito.ArgumentMatchers.any;
6+
import static org.mockito.ArgumentMatchers.eq;
7+
import static org.mockito.Mockito.*;
8+
9+
import java.io.ByteArrayInputStream;
10+
import java.io.ByteArrayOutputStream;
11+
import java.io.BufferedReader;
12+
import java.io.IOException;
13+
import java.io.InputStreamReader;
14+
import java.nio.charset.StandardCharsets;
15+
import java.util.Date;
16+
import java.util.List;
17+
import java.util.Map;
18+
import java.util.zip.GZIPInputStream;
19+
20+
import com.atlassian.bandana.BandanaManager;
21+
import com.atlassian.migration.app.gateway.AppCloudForgeMigrationGateway;
22+
import com.atlassian.migration.app.gateway.MigrationDetailsV1;
23+
import com.baloise.confluence.digitalsignature.Signature2;
24+
import com.google.gson.JsonObject;
25+
import com.google.gson.JsonParser;
26+
27+
import org.junit.jupiter.api.BeforeEach;
28+
import org.junit.jupiter.api.Test;
29+
30+
class DigitalSignatureMigrationListenerTest {
31+
32+
private BandanaManager bandanaManager;
33+
private DigitalSignatureMigrationListener listener;
34+
35+
@BeforeEach
36+
void setUp() {
37+
bandanaManager = mock(BandanaManager.class);
38+
listener = new DigitalSignatureMigrationListener(bandanaManager);
39+
}
40+
41+
@Test
42+
void macroMapping_returnsCorrectMapping() {
43+
Map<String, String> mapping = listener.getServerToForgeMacroMapping();
44+
assertEquals(1, mapping.size());
45+
assertEquals(
46+
DigitalSignatureMigrationListener.FORGE_MACRO_KEY,
47+
mapping.get(DigitalSignatureMigrationListener.SERVER_MACRO_KEY)
48+
);
49+
}
50+
51+
@Test
52+
void toJsonLine_includesRequiredFields() {
53+
Signature2 sig = new Signature2(42L, "my body", "my title");
54+
sig.getSignatures().put("alice", new Date(0));
55+
sig.getMissingSignatures().add("bob");
56+
sig.getNotify().add("carol");
57+
58+
String line = DigitalSignatureMigrationListener.toJsonLine(sig);
59+
JsonObject obj = new JsonParser().parse(line).getAsJsonObject();
60+
61+
assertAll(
62+
() -> assertEquals(sig.getHash(), obj.get("hash").getAsString()),
63+
() -> assertEquals(42L, obj.get("pageId").getAsLong()),
64+
() -> assertEquals("my title", obj.get("title").getAsString()),
65+
() -> assertEquals("my body", obj.get("body").getAsString()),
66+
() -> assertTrue(obj.has("signatures")),
67+
() -> assertTrue(obj.get("signatures").getAsJsonObject().has("alice")),
68+
// missingSignatures and notify must NOT appear in the export
69+
() -> assertFalse(obj.has("missingSignatures")),
70+
() -> assertFalse(obj.has("notify"))
71+
);
72+
}
73+
74+
@Test
75+
void onStartAppMigration_writesOneLinePerContract() throws Exception {
76+
Signature2 sig1 = new Signature2(10L, "body1", "title1");
77+
sig1.getSignatures().put("user1", new Date(1000));
78+
79+
Signature2 sig2 = new Signature2(20L, "body2", "title2");
80+
sig2.getSignatures().put("user2", new Date(2000));
81+
sig2.getSignatures().put("user3", new Date(3000));
82+
83+
List<String> keys = List.of(
84+
sig1.getKey(),
85+
sig2.getKey(),
86+
"protected.somehash", // should be skipped
87+
"other.key" // should be skipped
88+
);
89+
90+
when(bandanaManager.getKeys(GLOBAL_CONTEXT)).thenReturn(keys);
91+
when(bandanaManager.getValue(eq(GLOBAL_CONTEXT), eq(sig1.getKey()))).thenReturn(Signature2.GSON.toJson(sig1, Signature2.class));
92+
when(bandanaManager.getValue(eq(GLOBAL_CONTEXT), eq(sig2.getKey()))).thenReturn(Signature2.GSON.toJson(sig2, Signature2.class));
93+
94+
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
95+
AppCloudForgeMigrationGateway gateway = mock(AppCloudForgeMigrationGateway.class);
96+
when(gateway.createAppData("signatures")).thenReturn(buffer);
97+
98+
listener.onStartAppMigration(gateway, new MigrationDetailsV1());
99+
100+
verify(gateway).completeExport();
101+
102+
List<String> lines = gunzipLines(buffer.toByteArray());
103+
assertEquals(2, lines.size(), "Expected one JSONL line per signature contract");
104+
105+
JsonObject first = new JsonParser().parse(lines.get(0)).getAsJsonObject();
106+
JsonObject second = new JsonParser().parse(lines.get(1)).getAsJsonObject();
107+
108+
// Verify both contracts appear (order not guaranteed by Bandana)
109+
List<String> hashes = List.of(
110+
first.get("hash").getAsString(),
111+
second.get("hash").getAsString()
112+
);
113+
assertTrue(hashes.contains(sig1.getHash()));
114+
assertTrue(hashes.contains(sig2.getHash()));
115+
}
116+
117+
@Test
118+
void onStartAppMigration_skipNullEntries() throws Exception {
119+
String key = "signature.deadbeef";
120+
when(bandanaManager.getKeys(GLOBAL_CONTEXT)).thenReturn(List.of(key));
121+
// fromBandana returns null when key is not found in getKeys
122+
when(bandanaManager.getKeys(GLOBAL_CONTEXT)).thenReturn(List.of());
123+
124+
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
125+
AppCloudForgeMigrationGateway gateway = mock(AppCloudForgeMigrationGateway.class);
126+
when(gateway.createAppData("signatures")).thenReturn(buffer);
127+
128+
assertDoesNotThrow(() -> listener.onStartAppMigration(gateway, new MigrationDetailsV1()));
129+
verify(gateway).completeExport();
130+
131+
List<String> lines = gunzipLines(buffer.toByteArray());
132+
assertEquals(0, lines.size());
133+
}
134+
135+
// ---- helpers ----
136+
137+
private static List<String> gunzipLines(byte[] compressed) throws IOException {
138+
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed));
139+
BufferedReader reader = new BufferedReader(new InputStreamReader(gzip, StandardCharsets.UTF_8))) {
140+
return reader.lines().filter(l -> !l.isBlank()).toList();
141+
}
142+
}
143+
}

0 commit comments

Comments
 (0)