Всем привет!
В этой статье я расскажу Вам о том, как мигировать данные Вашего плагина для Jira с сервера в клауд с помощью Jira Cloud Migration Assistant (JCMA).
JCMA позволяет Вам мигрировать данные Jira Software и Jira Core с сервера в клауд.
Но, что если Ваш плагин использует данные, которые хранятся в сущностях, непереносимых JCMA? Например, в базе данных Jira с помощью Active Objects или на файловой структуре?
В этом случае JCMA не перенесет Ваши данные и Ваш плагин не будет корректно работать в клауде.
Поэтому для того, чтобы корректно перенести данные Вашего плагина, Вы можете написать расширение для JCMA и перенести Ваши данные так, как Вы считаете нужным.
Вы можете найти больше информации вот здесь, а также посмотреть примеры реализации вот здесь.
В этой статье я Вам расскажу, как это все работает и покажу на рабочем коде. Код можно взять вот здесь.
Видео можно посмотреть здесь.
Теория
Расширение для JCMA состоит из двух плагинов: для Jira Server/Data Center и для Jira Cloud.
Миграция запускается на Jira Server/Data Center. После того, как JCMA закончит миграцию своих данные (Jira Core и Jira Software), JCMA вызовет все сервисы, которые расширяют интерфейс CloudConnectionListener.
И все что нам нужно в серверном плагине, это реализовать этот интерфейс.
Затем JCMA посылает события в вебхуки, которые определны плагином для клауда. И поэтому в плагине для клауда нам нужно сначала создать точки входа для вебуков, а потом зарегистрировать вебхуки в JCMA.
Это все про теорию.
Плагин для Jira Server/Data Center
У нас в плагине два класса.
src/main/java/ru/matveev/alexey/atlassian/server/accessor/LocalCloudMigrationAccessor.java
@Named("cloudMigrationAccessor")
public class LocalCloudMigrationAccessor implements ApplicationContextAware {
private CloudMigrationAccessor registrar;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
try {
this.registrar = (CloudMigrationAccessor) applicationContext.getAutowireCapableBeanFactory().
createBean(getClass().getClassLoader().loadClass("com.atlassian.migration.app.tracker.CloudMigrationAccessor"), AutowireCapableBeanFactory.AUTOWIRE_CONSTRUCTOR, false);
} catch (Exception e) {
throw new RuntimeException("Failed to initialise CloudMigrationAccessor", e);
}
}
public CloudMigrationAccessor getCloudMigrationAccessor() {
return registrar;
}
}
Этот класс получает ссылку на сервис CloudMigrationAccessor. Нам этот сервис нужен, чтобы мигрировать наши данные.
src/main/java/ru/matveev/alexey/atlassian/server/impl/MyPluginComponentImpl.java
@Slf4j
@Named ("myPluginComponent")
public class MyPluginComponentImpl implements CloudMigrationListener, InitializingBean, DisposableBean {
private final CloudMigrationAccessor accessor;
@Inject
public MyPluginComponentImpl(LocalCloudMigrationAccessor accessor) {
// It is not safe to save a direct reference to the gateway as that can change over time
this.accessor = accessor.getCloudMigrationAccessor();
}
@Override
public void onRegistrationAccepted() {
log.info("Nice! The migration listener is ready to take migrations events");
}
/**
* Just a collection of example operations that you can run as part of a migration. None of them are actually required
*/
@Override
public void onStartAppMigration(String transferId, MigrationDetails migrationDetails) {
ObjectMapper objectMapper = new ObjectMapper();
try {
log.info("Migration context summary: " + objectMapper.writeValueAsString(migrationDetails));
PaginatedMapping paginatedMapping = accessor.getCloudMigrationGateway().getPaginatedMapping(transferId, "identity:user", 5);
while (paginatedMapping.next()) {
Map<String, String> mappings = paginatedMapping.getMapping();
log.info("mappings = {}", objectMapper.writeValueAsString(mappings));
}
} catch (IOException e) {
log.error("Error while running app migration", e);
}
// You can also upload one or more files to the cloud. You'll be able to retrieve them through Atlassian Connect
try {
OutputStream firstDataStream = accessor.getCloudMigrationGateway().createAppData(transferId);
// You can even upload big files in here
firstDataStream.write("Your binary data goes here".getBytes());
firstDataStream.close();
// You can also apply labels to distinguish files or to add meta data to support your import process
OutputStream secondDataStream = accessor.getCloudMigrationGateway().createAppData(transferId, "some-optional-label");
secondDataStream.write("more bytes".getBytes());
secondDataStream.close();
} catch (IOException e) {
log.error("Error uploading files to the cloud", e);
}
}
@Override
public void onRegistrarRemoved() {
log.info("The listener is no longer active");
}
@Override
public String getCloudAppKey() {
return "ru.matveev.alexey.atlassian.server.jcma-cloud";
}
@Override
public String getServerAppKey() {
return "ru.matveev.alexey.atlassian.server.jcma-server";
}
@Override
public Set<AccessScope> getDataAccessScopes() {
return Stream.of(
AccessScope.APP_DATA_OTHER,
AccessScope.PRODUCT_DATA_OTHER,
AccessScope.MIGRATION_TRACING_IDENTITY)
.collect(Collectors.toCollection(HashSet::new));
}
@Override
public void afterPropertiesSet() {
this.accessor.registerListener(this);
}
@Override
public void destroy() {
this.accessor.deregisterListener(this);
}
}
Здесь мы просто создали листенер от CloudMigrationListener, зарегистрировали и выгрузили наш листенер в методах afterPropertiesSet и destroy.
Метод onRegistrationAccepted вызывается JCMA после того, как наш листенер был успешно зарегистрирован.
Методы getCloudAppKey и getServerAppKey возварщают ключи наших плагинов для сервера и клауда.
И метод onStartAppMigration делает всю работу по миграции.
@Override
public void onStartAppMigration(String transferId, MigrationDetails migrationDetails) {
ObjectMapper objectMapper = new ObjectMapper();
try {
log.info("Migration context summary: " + objectMapper.writeValueAsString(migrationDetails));
PaginatedMapping paginatedMapping = accessor.getCloudMigrationGateway().getPaginatedMapping(transferId, "identity:user", 5);
while (paginatedMapping.next()) {
Map<String, String> mappings = paginatedMapping.getMapping();
log.info("mappings = {}", objectMapper.writeValueAsString(mappings));
}
} catch (IOException e) {
log.error("Error while running app migration", e);
}
// You can also upload one or more files to the cloud. You'll be able to retrieve them through Atlassian Connect
try {
OutputStream firstDataStream = accessor.getCloudMigrationGateway().createAppData(transferId);
// You can even upload big files in here
firstDataStream.write("Your binary data goes here".getBytes());
firstDataStream.close();
// You can also apply labels to distinguish files or to add meta data to support your import process
OutputStream secondDataStream = accessor.getCloudMigrationGateway().createAppData(transferId, "some-optional-label");
secondDataStream.write("more bytes".getBytes());
secondDataStream.close();
} catch (IOException e) {
log.error("Error uploading files to the cloud", e);
}
}
Как Вы видите передается два параметра: transferId и migrationDetails. Параметр transferId помогает Вашим серверным и клаудным плагинам работать с одним и тем же набором данных. Ведь Вы можете запустить бесконечное число миграций на сервере. Параметр migrationDetails содержит контекст Вашей миграции.
Еще хотел упомянуть о такой важной теме, как мэппинги. Мэппинги позволяют получать Вам ид объектов в клауде для серверных объектов, так как ид объектов при миграции с большой долей вероятности изменится. Например, Вы можете получить ид пользователя в клауде для пользователя на сервере.
В нашей реализации метода onStartAppMigration мы просто создаем два файла, которые будет загружены в облачное хранилище самим JCMA, а потом в нашем клаудном плагине мы сможем получит доступ к этим файлам. Предполагается, что эти файлы содержать данные, которые нужно мигрировать в клауд.
Конечно, мы могли бы начать и закончить миграцию прямо в методе onStartAppMigration. Напрмер, у нас есть какой-то клаудный REST API, которые принимает данные с сервера и кладет их, как нужно, в клауд. Тогда у нас нет надобности в клаудном плагине. Но это не всегда возможно, поэтому я сделал еще и клаудный плагин.
Плагин Jira Cloud
Сначала мы зарегистрируем наши вебхуки в JCMA. Это мы будем делать при установке плагина и выгрузим наши вебхуки при удалении нашего плагина.
Я использую atlassian-connect-spring-boot поэтому мне нужно ловить два события в спринговом контексте.
Для регистрации вебхука нужен url, где крутится наше приложение. Я могу его достать из application.yml. Поэтому начнем вот с этого файла: src/main/java/ru/matveev/alexey/atlassian/cloud/util/YAMLConfig.java:
@Configuration
@EnableConfigurationProperties
@ConfigurationProperties(prefix="addon")
@Data
public class YAMLConfig {
private String key;
private String baseUrl;
}
Здесь я просто достаю два параметра из секции addon в файле application.yml.
Вот теперь могу ловить события в спринговом контексте.
src/main/java/ru/matveev/alexey/atlassian/cloud/listener/AddonInstalledEventListener.java
@Component
@Slf4j
public class AddonInstalledEventListener implements ApplicationListener<com.atlassian.connect.spring.AddonInstalledEvent> {
@Autowired
private YAMLConfig yamlConfig;
@Autowired
private AtlassianHostRestClients atlassianHostRestClients;
@Override
public void onApplicationEvent(AddonInstalledEvent addonInstalledEvent) {
log.info("install event");
JSONArray endpoints = new JSONArray();
endpoints.put(String.format("%s/migration-started" , yamlConfig.getBaseUrl()));
JSONObject webhook = new JSONObject();
webhook.put("endpoints", endpoints);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(webhook.toString(), headers);
atlassianHostRestClients
.authenticatedAsAddon()
.put(String.format("%s/rest/atlassian-connect/1/migration/webhook", addonInstalledEvent.getHost().getBaseUrl()), request);
}
}
Как Вы видите, я инжекчу YamlConfig и AtlassianHostRestClients. YamlConfig мне дает url, а AtlassianHostRestClient позволяет пользоваться Jira Cloud Rest Api с необходимой аутентификацией.
Для события unistalled код примерно такой же.
Итак, мы зарегистрировали вебхук. Теперь напишем точку входа в наш вебхук src/main/java/ru/matveev/alexey/atlassian/cloud/listener/MigrationStartedListener.java:
@Controller
@Slf4j
public class MigrationStartedListener {
@Autowired
private AtlassianHostRestClients atlassianHostRestClients;
@PostMapping("/migration-started")
@ResponseBody
public String migrationStarted(@RequestBody JiraMigration body) {
log.info("migration started");
if ( "APP_DATA_UPLOADED".equals(body.getWebhookEventType())) {
ResponseEntity<String> response = atlassianHostRestClients.authenticatedAsAddon()
.getForEntity(String.format("%s/rest/atlassian-connect/1/migration/data/%s/all", body.getMigrationDetails().getCloudUrl(), body.getTransferId()), String.class);
log.info(response.getBody());
}
return "ok";
}
}
Мы определили точку входа migration-started. Эта точка входа будет вызывана JCMA. Мы принимаем класс JiraMigration в качесте пейлоада. Этот класс содержит всю необходимую информацию для того, чтобы мы успешно мигрировали данные. В том числе мы может найти там transerId и мэппинги. В данной реализации я использую transerId, чтобы получить файлы, которые я создал в серверном плагине.
Вот и весь пример, который показывает, как начать писать свои расширения для JCMA.