Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger-ui

src/pages/migrate.rs

//! **Migrate** — pull a whole Nexus or Artifactory into this holger.
//!
//! The screen an operator does a one-time registry migration from, and the one
//! place in the console that carries someone else's credential.
//!
//! # The flow this page is shaped around
//!
//! Discover first, always: it reads no artifact content, writes nothing, and
//! answers the question the operator actually has — *what is over there, and
//! what would taking it mean here?*
//!
//! Then the part no button can do:
//!
//! ```text
//!   Discover  →  paste the declarations into the config  →  restart  →  Start
//! ```
//!
//! **holger cannot create a repository while it is running.** `FastRoutes` is
//! built from the wired config at boot and is immutable for the life of the
//! process; there is no create-repository verb on any surface. So a repository
//! the source has and this holger does not is not a thing Start can do — it is a
//! thing a person must do first, by declaring it and restarting. This page makes
//! that the visible middle of the flow: a `create` row cannot be selected, it
//! shows the RON to paste instead, and the Start button says in words how many
//! repositories are waiting on a declaration. A Migrate button that started and
//! then refused per repository would be worse than no page at all.
//!
//! The preview text under the table is the server's `report` field — the SAME
//! renderer the CLI's `--dry-run` prints (`traits::migrate`) — so the terminal
//! and the console can never describe one plan in two ways. The declarations
//! come down the wire for the same reason.
//!
//! # The credential
//!
//! The console presents the OPERATOR's own bearer token (`grpc.rs`, sessionStorage,
//! dies with the tab); `MigrationService` is ADMIN-gated server-wide and fails
//! closed, so this page asks `WhoAmI` first and says plainly when the session
//! cannot call it, rather than offering buttons that will be refused.
//!
//! The SOURCE registry's password is a different secret and it is treated as a
//! parameter of one call: it is never written to storage, never put in a URL,
//! never logged, never rendered, and it is cleared out of the form — signal and
//! DOM input both — the moment the request has been built. Discover and Start are
//! therefore two separate typings of it, deliberately. Everything the page says
//! about the source is rendered by `util`, which never receives the password in
//! any string-producing path; `util::tests::a_source_password_never_reaches_\
//! anything_the_page_renders` is the assertion.

use std::collections::BTreeMap;

use leptos::prelude::*;
use leptos::task::spawn_local;

use crate::components::copy_button::CopyButton;
use crate::components::feedback::{Badge, FailBox};
use crate::grpc;
use crate::util::{
    declare_first_note, disposition_note, disposition_tone, fmt_bytes, forgotten,
    migration_headline, migration_outcome, migration_percent, migration_progress_line, migration_repo_outcome,
    migration_role_refusal, migration_row_selectable, source_form_error, source_summary,
    start_plan, start_plan_note, SourceForm, StartPlan, MIGRATION_KIND_ARTIFACTORY,
    MIGRATION_KIND_NEXUS,
};

type Progress = BTreeMap<String, grpc::pb::MigrationProgressEvent>;

/// The form, on the wire. `only` overrides the form's own repository field when
/// the page is running one job per selected repository.
fn wire_source(form: &SourceForm, only: &str) -> grpc::pb::MigrationSource {
    grpc::pb::MigrationSource {
        kind: form.kind,
        base_url: form.base_url.trim().to_string(),
        username: form.username.trim().to_string(),
        password: form.password.clone(),
        repository: if only.is_empty() {
            form.repository.trim().to_string()
        } else {
            only.to_string()
        },
    }
}

/// Follow one job to its end. A watcher that joins late gets the latest row per
/// repository first and then follows live, so this is also the reconnect path.
async fn follow(
    job_id: String,
    rows: RwSignal<Progress>,
    log: RwSignal<Vec<String>>,
) -> Result<(), tonic::Status> {
    let mut stream = grpc::migration()
        .watch(grpc::pb::WatchMigrationRequest { job_id })
        .await?
        .into_inner();
    while let Some(event) = stream.message().await? {
        let finished = event.job_finished;
        let key = if event.repo.is_empty() {
            "(job)".to_string()
        } else {
            event.repo.clone()
        };
        if !event.message.is_empty() {
            let line = format!("{} · {}", crate::webio::now_hms(), event.message);
            log.update(|l| l.push(line));
        }
        rows.update(|m| {
            m.insert(key, event);
        });
        if finished {
            break;
        }
    }
    Ok(())
}

#[component]
pub fn Migrate() -> impl IntoView {
    crate::webio::set_title("Migrate");

    let form = RwSignal::new(SourceForm::default());
    let password_input: NodeRef<leptos::html::Input> = NodeRef::new();
    let plan: RwSignal<Option<grpc::pb::DiscoverMigrationResponse>> = RwSignal::new(None);
    let selected: RwSignal<Vec<String>> = RwSignal::new(Vec::new());
    let discovered_at: RwSignal<Option<String>> = RwSignal::new(None);
    let error: RwSignal<Option<String>> = RwSignal::new(None);
    let fail: RwSignal<Option<grpc::Fail>> = RwSignal::new(None);
    let busy = RwSignal::new(false);
    let running = RwSignal::new(false);
    let stopping = RwSignal::new(false);
    let rows: RwSignal<Progress> = RwSignal::new(BTreeMap::new());
    let current_job: RwSignal<Option<String>> = RwSignal::new(None);
    let log: RwSignal<Vec<String>> = RwSignal::new(Vec::new());

    // The server's view of this session. The migration door is ADMIN-gated and
    // fails closed, so the page says up front whether this credential can call
    // it at all instead of letting the operator find out by typing a password
    // into a form that was never going to work.
    let identity = LocalResource::new(|| async { grpc::who_am_i().await.ok() });

    // Clear the secret from BOTH places a browser holds it: the signal, and the
    // input element's own value. Called the instant a request has been built —
    // before it is even awaited.
    let forget_password = move || {
        form.update(|f| *f = forgotten(f));
        if let Some(el) = password_input.get_untracked() {
            el.set_value("");
        }
    };

    let discover = move |_| {
        if busy.get_untracked() || running.get_untracked() {
            return;
        }
        let f = form.get_untracked();
        if let Some(refusal) = source_form_error(&f) {
            error.set(Some(refusal));
            return;
        }
        let source = wire_source(&f, "");
        forget_password();
        busy.set(true);
        error.set(None);
        fail.set(None);
        spawn_local(async move {
            let res = grpc::migration()
                .discover(grpc::pb::DiscoverMigrationRequest {
                    source: Some(source),
                })
                .await;
            busy.set(false);
            match res {
                Ok(r) => {
                    let response = r.into_inner();
                    // Pre-select exactly what Start can actually fill today.
                    selected.set(
                        response
                            .repos
                            .iter()
                            .filter(|p| migration_row_selectable(&p.disposition))
                            .map(|p| p.source_repo.clone())
                            .collect(),
                    );
                    rows.set(BTreeMap::new());
                    log.set(Vec::new());
                    discovered_at.set(Some(crate::webio::now_hms()));
                    plan.set(Some(response));
                }
                Err(s) => {
                    plan.set(None);
                    fail.set(Some(grpc::fail(&s)));
                }
            }
        });
    };

    let start = move |_| {
        if busy.get_untracked() || running.get_untracked() {
            return;
        }
        let f = form.get_untracked();
        if let Some(refusal) = source_form_error(&f) {
            error.set(Some(refusal));
            return;
        }
        let Some(p) = plan.get_untracked() else {
            return;
        };
        let all: Vec<String> = p.repos.iter().map(|r| r.source_repo.clone()).collect();
        let queue: Vec<String> = match start_plan(&all, &selected.get_untracked()) {
            StartPlan::Nothing => {
                error.set(Some(
                    "select at least one repository that is already declared here".into(),
                ));
                return;
            }
            // Empty = the whole registry, which is what the wire means by it.
            StartPlan::WholeRegistry => vec![String::new()],
            StartPlan::PerRepository(v) => v,
        };

        // The password is taken ONCE, into the task that needs it, and the form
        // forgets it here — before a single byte has left the browser.
        let password = f.password.clone();
        let base = f.clone();
        forget_password();

        error.set(None);
        fail.set(None);
        rows.set(BTreeMap::new());
        log.set(Vec::new());
        running.set(true);
        stopping.set(false);

        spawn_local(async move {
            for only in queue {
                if stopping.get_untracked() {
                    break;
                }
                let mut source = wire_source(&base, &only);
                source.password = password.clone();
                let started = grpc::migration()
                    .start(grpc::pb::StartMigrationRequest {
                        source: Some(source),
                        concurrency: 0,
                        // Left false ON PURPOSE: the safe state is the zero
                        // value, so every published digest is verified.
                        skip_digest_verification: false,
                    })
                    .await;
                let job_id = match started {
                    Ok(r) => {
                        let r = r.into_inner();
                        let what = if only.is_empty() {
                            "whole registry".to_string()
                        } else {
                            only.clone()
                        };
                        let line = format!(
                            "{} · job {} started · {what} · {}",
                            crate::webio::now_hms(),
                            r.job_id,
                            r.message
                        );
                        log.update(|l| l.push(line));
                        r.job_id
                    }
                    Err(s) => {
                        fail.set(Some(grpc::fail(&s)));
                        break;
                    }
                };
                current_job.set(Some(job_id.clone()));
                if let Err(s) = follow(job_id, rows, log).await {
                    fail.set(Some(grpc::fail(&s)));
                    break;
                }
                current_job.set(None);
            }
            running.set(false);
            current_job.set(None);
            // `password` dies with this task, and it was never anywhere else.
        });
    };

    let cancel = move |_| {
        stopping.set(true);
        let Some(job_id) = current_job.get_untracked() else {
            return;
        };
        spawn_local(async move {
            match grpc::migration()
                .cancel(grpc::pb::CancelMigrationRequest { job_id })
                .await
            {
                Ok(r) => {
                    let line = format!("{} · {}", crate::webio::now_hms(), r.into_inner().message);
                    log.update(|l| l.push(line));
                }
                Err(s) => fail.set(Some(grpc::fail(&s))),
            }
        });
    };

    let toggle = move |name: String, on: bool| {
        selected.update(|s| {
            if on {
                if !s.contains(&name) {
                    s.push(name);
                }
            } else {
                s.retain(|n| n != &name);
            }
        });
    };

    let select_all = move |on: bool| {
        let Some(p) = plan.get_untracked() else {
            return;
        };
        selected.set(if on {
            p.repos
                .iter()
                .filter(|r| migration_row_selectable(&r.disposition))
                .map(|r| r.source_repo.clone())
                .collect()
        } else {
            Vec::new()
        });
    };

    view! {
        <section class="page">
            <crate::pages::repositories::RepoTabs/>
            <h2>"Migrate a registry into holger"</h2>
            <p class="count measurenote">
                "Reads a Nexus or Artifactory and moves what it finds into this server. \
                 Discover writes nothing and reads no artifact content — it is the preview, \
                 and it is where every migration starts."
            </p>

            // ── Can this session even call the door? ─────────────────────────
            {move || match identity.get().flatten() {
                None => Some(view! {
                    <p class="banner banner-warn" role="status">
                        "The server did not answer WhoAmI, so this console cannot say which \
                         identity it holds. Every migration RPC is ADMIN-gated and fails closed."
                    </p>
                }.into_any()),
                Some(id) => migration_role_refusal(id.authenticated, &id.global_role).map(|why| view! {
                    <p class="banner banner-bad" role="alert">
                        {why}
                        " "
                        <a href="/account">"Account"</a>
                    </p>
                }.into_any()),
            }}

            // ── The source ───────────────────────────────────────────────────
            <div class="axes">
                <label class="axis" for="mig-kind">
                    <span>"Source"</span>
                    <select id="mig-kind" on:change=move |ev| {
                        let v = event_target_value(&ev);
                        let kind = if v == "artifactory" { MIGRATION_KIND_ARTIFACTORY } else { MIGRATION_KIND_NEXUS };
                        form.update(|f| f.kind = kind);
                    }>
                        <option value="nexus" selected>"Nexus"</option>
                        <option value="artifactory">"Artifactory"</option>
                    </select>
                </label>
                <label class="axis" for="mig-url">
                    <span>"Base URL"</span>
                    <input id="mig-url" type="url" inputmode="url" autocomplete="off"
                        placeholder="https://nexus.example.internal"
                        prop:value=move || form.with(|f| f.base_url.clone())
                        on:input=move |ev| { let v = event_target_value(&ev); form.update(|f| f.base_url = v); }/>
                </label>
                <label class="axis" for="mig-user">
                    <span>"User"</span>
                    <input id="mig-user" type="text" autocomplete="off"
                        prop:value=move || form.with(|f| f.username.clone())
                        on:input=move |ev| { let v = event_target_value(&ev); form.update(|f| f.username = v); }/>
                </label>
                <label class="axis" for="mig-pass">
                    <span>"Password"</span>
                    // Never stored, never rendered, never in a URL. Cleared from
                    // this element and from the page's state as soon as a request
                    // has been built — so Discover and Start each need it typed.
                    <input id="mig-pass" type="password" autocomplete="off" node_ref=password_input
                        on:input=move |ev| { let v = event_target_value(&ev); form.update(|f| f.password = v); }/>
                </label>
                <label class="axis" for="mig-repo">
                    <span>"One repository (optional)"</span>
                    <input id="mig-repo" type="text" autocomplete="off" placeholder="blank = every repository"
                        prop:value=move || form.with(|f| f.repository.clone())
                        on:input=move |ev| { let v = event_target_value(&ev); form.update(|f| f.repository = v); }/>
                </label>
            </div>
            <p class="count">{move || form.with(source_summary)}</p>
            <p class="count measurenote">
                "The source password is used for the one call you press and then forgotten — \
                 it is never stored, never logged and never put in a URL. Type it again before Start."
            </p>
            <div class="toolbar">
                <button on:click=discover disabled=move || busy.get() || running.get()>
                    {move || if busy.get() { "Discovering…" } else { "Discover" }}
                </button>
            </div>

            {move || error.get().map(|e| view! { <p class="err" role="alert">{e}</p> })}
            {move || fail.get().map(|f| view! {
                <FailBox context="migration" fail=Signal::derive(move || f.clone())/>
            })}

            // ── The plan ─────────────────────────────────────────────────────
            {move || plan.get().map(|p| {
                let to_declare = p.repos.iter().filter(|r| r.disposition == "create").count();
                let fillable = p.repos.iter().filter(|r| migration_row_selectable(&r.disposition)).count();
                let refused = p.repos.iter().filter(|r| !r.refusal_reason.is_empty()).count();
                let declarations: Vec<(String, String)> = p.repos.iter()
                    .filter(|r| !r.declaration.is_empty())
                    .map(|r| (r.target_repo.clone(), r.declaration.clone()))
                    .collect();
                let all_declarations = declarations.iter()
                    .map(|(_, d)| d.clone())
                    .collect::<Vec<_>>()
                    .join("\n");
                let refusals = p.refusals.clone();
                let report = p.report.clone();
                let total_assets = p.total_assets;
                let total_bytes = p.total_bytes;
                let repo_rows = p.repos.clone();
                let stamp = discovered_at.get().unwrap_or_default();

                view! {
                    <h3>{format!("Discovered · {stamp}")}</h3>
                    <p class="count">
                        {migration_headline(p.repos.len(), fillable, to_declare, refused)}
                        {format!(" · {} assets · {} advertised", total_assets, fmt_bytes(total_bytes))}
                    </p>

                    {declare_first_note(to_declare).map(|note| view! {
                        <p class="banner banner-warn" role="alert">{note}</p>
                    })}

                    <div class="toolbar">
                        <button class="quiet" on:click=move |_| select_all(true)>"Select all fillable"</button>
                        <button class="quiet" on:click=move |_| select_all(false)>"Select none"</button>
                    </div>

                    <div class="tablewrap">
                        <table class="report">
                            <thead>
                                <tr>
                                    <th scope="col"><span class="vh">"Selected"</span></th>
                                    <th scope="col">"Source repository"</th>
                                    <th scope="col">"Source format"</th>
                                    <th scope="col">"Target here"</th>
                                    <th scope="col">"holger format"</th>
                                    <th scope="col">"Disposition"</th>
                                    <th scope="col" class="num">"Assets"</th>
                                    <th scope="col" class="num">"Bytes"</th>
                                </tr>
                            </thead>
                            <tbody>
                                {repo_rows.into_iter().map(|r| {
                                    let name = r.source_repo.clone();
                                    let for_toggle = name.clone();
                                    let selectable = migration_row_selectable(&r.disposition);
                                    let tone = disposition_tone(&r.disposition);
                                    let note = disposition_note(&r.disposition);
                                    let reason = r.refusal_reason.clone();
                                    let checked = {
                                        let n = name.clone();
                                        move || selected.with(|s| s.contains(&n))
                                    };
                                    view! {
                                        <tr>
                                            <td>
                                                <input type="checkbox" disabled=!selectable
                                                    aria-label=format!("select {name}")
                                                    prop:checked=checked
                                                    on:change=move |ev| {
                                                        let on = event_target_checked(&ev);
                                                        toggle(for_toggle.clone(), on);
                                                    }/>
                                            </td>
                                            <td class="mono">{r.source_repo.clone()}</td>
                                            <td class="mono">{r.source_format.clone()}</td>
                                            <td class="mono">{r.target_repo.clone()}</td>
                                            <td class="mono">
                                                {if r.target_format.is_empty() { "—".to_string() } else { r.target_format.clone() }}
                                            </td>
                                            <td>
                                                <Badge tone=tone label=r.disposition.clone()/>
                                                <span class="count">{format!(" {note}")}</span>
                                                {(!reason.is_empty()).then(|| view! {
                                                    <p class="err">{reason.clone()}</p>
                                                })}
                                            </td>
                                            <td class="num">{r.assets}</td>
                                            <td class="num">{fmt_bytes(r.bytes)}</td>
                                        </tr>
                                    }
                                }).collect_view()}
                            </tbody>
                        </table>
                    </div>

                    // ── What a person must do before Start can fill these ────
                    {(!declarations.is_empty()).then(|| view! {
                        <h3>{format!("Declare these {} first, then restart holger", declarations.len())}</h3>
                        <p class="count measurenote">
                            "holger builds its route table from the config at boot and it is immutable \
                             for the life of the process — there is no create-repository verb on any \
                             surface. Paste each line into the wired config, fill in the storage paths, \
                             restart, then Discover again: these rows come back as fillable."
                        </p>
                        <div class="toolbar">
                            <CopyButton label="all declarations"
                                value=Signal::derive({ let a = all_declarations.clone(); move || a.clone() })/>
                        </div>
                        {declarations.into_iter().map(|(repo, decl)| {
                            let value = decl.clone();
                            view! {
                                <div class="cardline">
                                    <span class="mono">{repo}</span>
                                    <CopyButton label="declaration"
                                        value=Signal::derive(move || value.clone())/>
                                </div>
                                <pre class="doc">{decl}</pre>
                            }
                        }).collect_view()}
                    })}

                    {(!refusals.is_empty()).then(|| view! {
                        <h3>{format!("Refused ({})", refusals.len())}</h3>
                        <p class="banner banner-bad" role="alert">
                            "This migration plan is REFUSED as a whole: a whole-registry run will not start until \
                             these are resolved. Repositories that are declared here can still be \
                             migrated one at a time — select them and Start."
                        </p>
                        <ul class="pathlist">
                            {refusals.into_iter().map(|r| view! { <li class="mono">{r}</li> }).collect_view()}
                        </ul>
                    })}

                    <h3>"Preview"</h3>
                    <p class="count measurenote">
                        "The server's own dry-run text, rendered by the same function the CLI prints — \
                         the terminal and this page cannot describe one plan in two ways."
                    </p>
                    <pre class="doc">{report}</pre>

                    // ── Start ────────────────────────────────────────────────
                    <h3>"Start"</h3>
                    <p class="count">
                        {move || {
                            let all: Vec<String> = plan.get()
                                .map(|p| p.repos.iter().map(|r| r.source_repo.clone()).collect())
                                .unwrap_or_default();
                            start_plan_note(&start_plan(&all, &selected.get()))
                        }}
                    </p>
                    <div class="toolbar">
                        <button class="danger-big" on:click=start
                            disabled=move || running.get() || busy.get() || selected.with(Vec::is_empty)>
                            {move || if running.get() { "Migrating…" } else { "Start migration" }}
                        </button>
                        <button class="danger" on:click=cancel
                            disabled=move || !running.get() || stopping.get()>
                            {move || if stopping.get() { "Stopping…" } else { "Cancel" }}
                        </button>
                    </div>
                }
            })}

            // ── Live progress ────────────────────────────────────────────────
            {move || {
                let progress = rows.get();
                (!progress.is_empty()).then(|| view! {
                    <h3>"Progress"</h3>
                    <div class="tablewrap">
                        <table class="report">
                            <thead>
                                <tr>
                                    <th scope="col">"Repository"</th>
                                    <th scope="col" class="num">"%"</th>
                                    <th scope="col">"Detail"</th>
                                    <th scope="col">"State"</th>
                                </tr>
                            </thead>
                            <tbody>
                                {progress.into_iter().map(|(repo, e)| {
                                    let pct = migration_percent(e.done, e.skipped, e.total);
                                    let line = migration_progress_line(e.done, e.skipped, e.failed, e.total, e.bytes);
                                    // `complete` is only meaningful on the job's
                                    // terminal row; the per-repository event
                                    // always carries `complete: false`, so a
                                    // repository is judged on its own counters.
                                    let state = if e.job_finished {
                                        let (text, tone) = migration_outcome(e.complete, e.cancelled, e.failed);
                                        view! { <Badge tone=tone label=text/> }.into_any()
                                    } else if e.repo_finished {
                                        let (text, tone) =
                                            migration_repo_outcome(e.done, e.skipped, e.failed, e.total, e.cancelled);
                                        view! { <Badge tone=tone label=text/> }.into_any()
                                    } else {
                                        view! { <span class="pending">"moving…"</span> }.into_any()
                                    };
                                    view! {
                                        <tr>
                                            <td class="mono">{repo}</td>
                                            <td class="num">{format!("{pct}%")}</td>
                                            <td class="count">{line}</td>
                                            <td>{state}</td>
                                        </tr>
                                    }
                                }).collect_view()}
                            </tbody>
                        </table>
                    </div>
                })
            }}

            {move || {
                let lines = log.get();
                (!lines.is_empty()).then(|| view! {
                    <h3>"Job log"</h3>
                    <pre class="doc">{lines.join("\n")}</pre>
                })
            }}
        </section>
    }
}