Мигрируем данные нашего плагина с помощью Jira Cloud Migration Assistant

Всем привет!

В этой статье я расскажу Вам о том, как мигировать данные Вашего плагина для 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.

If you have found a spelling error, please, notify us by selecting that text and pressing Ctrl+Enter.

Leave a Reply

%d bloggers like this:

Spelling error report

The following text will be sent to our editors: