feat(#2554): add hivectl forge reconcile-config to reconcile local applied config against forge main
This commit is contained in:
parent
11df4a1bf5
commit
d124dd205a
9 changed files with 335 additions and 1 deletions
|
|
@ -7,6 +7,7 @@ This document contains the help content for the `hivectl` command-line program.
|
|||
* [`hivectl`↴](#hivectl)
|
||||
* [`hivectl forge`↴](#hivectl-forge)
|
||||
* [`hivectl forge create-user`↴](#hivectl-forge-create-user)
|
||||
* [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config)
|
||||
* [`hivectl matrix`↴](#hivectl-matrix)
|
||||
* [`hivectl matrix create-user`↴](#hivectl-matrix-create-user)
|
||||
* [`hivectl matrix sync-admin`↴](#hivectl-matrix-sync-admin)
|
||||
|
|
@ -99,6 +100,7 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
|
|||
###### **Subcommands:**
|
||||
|
||||
* `create-user` — Create or refresh the Forgejo account + token for `<name>`
|
||||
* `reconcile-config` — Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main
|
||||
|
||||
|
||||
|
||||
|
|
@ -121,6 +123,32 @@ For an existing agent, persists the token to its state dir; for a human/other ac
|
|||
|
||||
|
||||
|
||||
## `hivectl forge reconcile-config`
|
||||
|
||||
Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main.
|
||||
|
||||
Always prints the diff first. `--from forge` resets the local checkout to forge main (effective on the next deploy); `--from local` is not supported yet. With no `--from`, prompts for the direction.
|
||||
|
||||
**Usage:** `hivectl forge reconcile-config [OPTIONS] <AGENT>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<AGENT>` — Agent whose config branches to reconcile
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--from <FROM>` — Which side to reconcile from. Omit to be prompted after the diff
|
||||
|
||||
Possible values:
|
||||
- `forge`:
|
||||
Reset the local applied checkout to forge main
|
||||
- `local`:
|
||||
Advance forge main from local — not supported yet
|
||||
|
||||
* `--verbose` — Include the full diff (not just `--stat`) in the report
|
||||
|
||||
|
||||
|
||||
## `hivectl matrix`
|
||||
|
||||
matrix-tuwunel user provisioning.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ hivectl forge create-user iris # provision (or refresh) forge account f
|
|||
hivectl forge create-user mara # create forge account for a human user; prints token to stdout
|
||||
hivectl forge create-user mara --password hunter2 # set a web-login password
|
||||
hivectl forge create-user mara --password-stdin # read password from stdin (safer for scripting)
|
||||
|
||||
hivectl forge reconcile-config iris # show local-applied <-> forge config divergence, then prompt
|
||||
hivectl forge reconcile-config iris --from forge # reset local applied checkout to forge main (effective next deploy)
|
||||
hivectl forge reconcile-config iris --verbose # include the full diff, not just --stat
|
||||
```
|
||||
|
||||
- For **agents** (name has a state dir under `/var/lib/hyperhive/agents/`):
|
||||
|
|
@ -38,6 +42,12 @@ hivectl forge create-user mara --password-stdin # read password from stdin (s
|
|||
re-mints the token and prints it again — safe for password resets.
|
||||
- Without `--password` / `--password-stdin` a random throwaway password
|
||||
is used (fine for agents — they auth by token).
|
||||
- `reconcile-config <agent>` shows the divergence between the agent's local
|
||||
applied config checkout and its forge `agent-configs/<agent>` `main`, then
|
||||
reconciles. `--from forge` resets the local checkout to forge `main` (takes
|
||||
effect on the next deploy — it does not auto-rebuild). `--from local` is not
|
||||
supported yet (forge `main` is core-only branch-protected; resolve via a
|
||||
config PR). With no `--from` it prompts for the direction after the diff.
|
||||
|
||||
## Matrix
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
mod ci_runner;
|
||||
pub mod config_pr_poll;
|
||||
mod pr_merge;
|
||||
mod reconcile;
|
||||
mod repos;
|
||||
mod users;
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ pub use pr_merge::{
|
|||
ForgeMergeError, config_repo, fetch_pr_head_into_applied, merge_config_pr_ff, post_pr_comment,
|
||||
pr_head_sha, pr_is_open,
|
||||
};
|
||||
pub use reconcile::{reconcile_config_apply, reconcile_config_status};
|
||||
pub use repos::{
|
||||
create_agent_repo, ensure_config_repo, ensure_knowledge_repo, ensure_meta_remote, ensure_repo,
|
||||
ensure_shared_docs_repo, meta_read_access, push_config, push_meta, shared_docs_access,
|
||||
|
|
|
|||
157
hive-c0re/src/forge/reconcile.rs
Normal file
157
hive-c0re/src/forge/reconcile.rs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
//! Reconcile an agent's local applied config checkout against its forge
|
||||
//! `agent-configs/<agent>` `main`. Backs `hivectl forge reconcile-config`
|
||||
//! via the host-socket `ReconcileConfig{Status,Apply}` requests. hive-c0re
|
||||
//! owns the applied checkout + the forge credential, so the reconcile op
|
||||
//! lives here rather than in the CLI.
|
||||
//!
|
||||
//! `Status` is read-only (fetches forge `main` into a scratch ref, reports
|
||||
//! the divergence). `Apply(Forge)` resets the local applied checkout to
|
||||
//! forge `main` — the change takes effect on the next deploy (the deploy's
|
||||
//! `--override-input` re-locks against the reset local tree); it does NOT
|
||||
//! auto-deploy. `Apply(Local)` is not supported: forge `main` is core-only
|
||||
//! branch-protected (advanced solely by the config-PR ff-merge API).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use hive_host_sock::{HostResponse, ReconcileDirection};
|
||||
|
||||
use super::{CONFIG_ORG, core_token, forge_git_url, is_present};
|
||||
|
||||
/// Scratch ref the forge `main` is fetched into — outside the normal
|
||||
/// branch/tag namespace so it never collides with real refs.
|
||||
const FORGE_MAIN_REF: &str = "refs/hyperhive/forge-config-main";
|
||||
|
||||
/// Fetch `agent-configs/<agent>` `main` into the applied repo's scratch
|
||||
/// ref (read-only, no working-tree change) and return the applied dir.
|
||||
async fn fetch_forge_main(agent: &str) -> Result<PathBuf> {
|
||||
if !is_present().await {
|
||||
anyhow::bail!("forge is not running");
|
||||
}
|
||||
let Some(token) = core_token() else {
|
||||
anyhow::bail!("forge core token not available");
|
||||
};
|
||||
let dir = crate::paths::applied_dir(agent);
|
||||
if !dir.join(".git").exists() {
|
||||
anyhow::bail!("agent `{agent}` has no applied config checkout");
|
||||
}
|
||||
let url = forge_git_url(&token, &format!("{CONFIG_ORG}/{agent}"));
|
||||
crate::lifecycle::git(
|
||||
&dir,
|
||||
&["fetch", "--force", &url, &format!("main:{FORGE_MAIN_REF}")],
|
||||
)
|
||||
.await
|
||||
.context("fetch forge config main (does forge main exist yet?)")?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Run a git command in `dir` and capture stdout, erroring on non-zero.
|
||||
async fn git_out(dir: &Path, args: &[&str]) -> Result<String> {
|
||||
let out = crate::lifecycle::git_command()
|
||||
.current_dir(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("invoke git {args:?}"))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Append `lines` (from a git text block) to `messages`, each indented.
|
||||
fn push_indented(messages: &mut Vec<String>, block: &str) {
|
||||
messages.extend(block.lines().map(|l| format!(" {l}")));
|
||||
}
|
||||
|
||||
/// Report the divergence between the local applied checkout and forge
|
||||
/// `main` (host request `ReconcileConfigStatus`). Read-only.
|
||||
pub async fn reconcile_config_status(agent: &str, verbose: bool) -> Result<HostResponse> {
|
||||
let dir = fetch_forge_main(agent).await?;
|
||||
let counts = git_out(
|
||||
&dir,
|
||||
&[
|
||||
"rev-list",
|
||||
"--left-right",
|
||||
"--count",
|
||||
&format!("HEAD...{FORGE_MAIN_REF}"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let mut parts = counts.split_whitespace();
|
||||
let local_ahead = parts.next().unwrap_or("0");
|
||||
let forge_ahead = parts.next().unwrap_or("0");
|
||||
let mut messages = vec![
|
||||
format!("agent {agent}: local applied main <-> forge agent-configs/{agent} main"),
|
||||
format!(" local ahead by {local_ahead}, forge ahead by {forge_ahead} commit(s)"),
|
||||
];
|
||||
if local_ahead == "0" && forge_ahead == "0" {
|
||||
messages.push(" in sync — nothing to reconcile".to_owned());
|
||||
return Ok(HostResponse::messages(messages));
|
||||
}
|
||||
if forge_ahead != "0" {
|
||||
messages.push(" forge-only commits (applied by --from forge):".to_owned());
|
||||
push_indented(
|
||||
&mut messages,
|
||||
&git_out(
|
||||
&dir,
|
||||
&["log", "--oneline", &format!("HEAD..{FORGE_MAIN_REF}")],
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
if local_ahead != "0" {
|
||||
messages.push(" local-only commits (discarded by --from forge):".to_owned());
|
||||
push_indented(
|
||||
&mut messages,
|
||||
&git_out(
|
||||
&dir,
|
||||
&["log", "--oneline", &format!("{FORGE_MAIN_REF}..HEAD")],
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
messages.push(" diff --stat (local HEAD -> forge main):".to_owned());
|
||||
push_indented(
|
||||
&mut messages,
|
||||
&git_out(&dir, &["diff", "--stat", "HEAD", FORGE_MAIN_REF]).await?,
|
||||
);
|
||||
if verbose {
|
||||
messages.push(" full diff:".to_owned());
|
||||
push_indented(
|
||||
&mut messages,
|
||||
&git_out(&dir, &["diff", "HEAD", FORGE_MAIN_REF]).await?,
|
||||
);
|
||||
}
|
||||
Ok(HostResponse::messages(messages))
|
||||
}
|
||||
|
||||
/// Apply a reconcile in `direction` (host request `ReconcileConfigApply`).
|
||||
/// `Forge` resets the local applied checkout to forge `main`; `Local` is
|
||||
/// unsupported and returns a clear error.
|
||||
pub async fn reconcile_config_apply(
|
||||
agent: &str,
|
||||
direction: ReconcileDirection,
|
||||
) -> Result<HostResponse> {
|
||||
match direction {
|
||||
ReconcileDirection::Local => Ok(HostResponse::error(
|
||||
"reconciling forge from local is not supported yet: forge main is core-only \
|
||||
branch-protected (no-push, ff-merge-API-only). Resolve via a config PR, or use \
|
||||
`--from forge` to reset the local checkout to forge main.",
|
||||
)),
|
||||
ReconcileDirection::Forge => {
|
||||
let dir = fetch_forge_main(agent).await?;
|
||||
crate::lifecycle::git(&dir, &["reset", "--hard", FORGE_MAIN_REF])
|
||||
.await
|
||||
.context("reset applied main to forge main")?;
|
||||
Ok(HostResponse::messages(vec![
|
||||
format!("reconciled applied/{agent} to forge agent-configs/{agent} main"),
|
||||
format!("effective on next deploy — rebuild {agent} to apply now"),
|
||||
]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -196,6 +196,12 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
HostRequest::ForgeCreateUser { name, password } => {
|
||||
handle_forge_create_user(name, password.as_deref()).await?
|
||||
}
|
||||
HostRequest::ReconcileConfigStatus { agent, verbose } => {
|
||||
crate::forge::reconcile_config_status(agent, *verbose).await?
|
||||
}
|
||||
HostRequest::ReconcileConfigApply { agent, direction } => {
|
||||
crate::forge::reconcile_config_apply(agent, *direction).await?
|
||||
}
|
||||
HostRequest::GatewayCreateUser { username, password } => {
|
||||
HostResponse::messages(vec![crate::gateway_nginx::create_user(username, password)?])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,17 @@ pub fn container_name(name: &str) -> String {
|
|||
format!("{AGENT_PREFIX}{name}")
|
||||
}
|
||||
|
||||
/// Which way to reconcile an agent's config branches
|
||||
/// ([`HostRequest::ReconcileConfigApply`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReconcileDirection {
|
||||
/// Reset the local applied checkout to forge `main`.
|
||||
Forge,
|
||||
/// Advance forge `main` from local — not supported yet.
|
||||
Local,
|
||||
}
|
||||
|
||||
/// Requests on the host admin socket.
|
||||
///
|
||||
/// Wire format: one JSON object per line.
|
||||
|
|
@ -200,6 +211,28 @@ pub enum HostRequest {
|
|||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
},
|
||||
/// Report the divergence between agent `agent`'s local applied config
|
||||
/// checkout and its forge `agent-configs/<agent>` `main`. The daemon
|
||||
/// fetches forge `main` read-only and returns a human-readable report
|
||||
/// (ahead/behind counts, commit-range summary, `git diff --stat`, and
|
||||
/// the full diff when `verbose`) in [`HostResponse::messages`]. Read-only
|
||||
/// — never mutates either side. Backs `hivectl forge reconcile-config`
|
||||
/// (the diff it always shows first).
|
||||
ReconcileConfigStatus {
|
||||
agent: String,
|
||||
#[serde(default)]
|
||||
verbose: bool,
|
||||
},
|
||||
/// Reconcile agent `agent`'s config branches in `direction`.
|
||||
/// `Forge` resets the local applied checkout to forge `main` (takes
|
||||
/// effect on the next deploy); `Local` is not supported yet and returns
|
||||
/// an [`HostResponse::error`] (advancing the protected forge `main` from
|
||||
/// local needs lifting branch protection — resolve via a config PR).
|
||||
/// Backs `hivectl forge reconcile-config --from <forge|local>`.
|
||||
ReconcileConfigApply {
|
||||
agent: String,
|
||||
direction: ReconcileDirection,
|
||||
},
|
||||
/// Add or update a gateway HTTP-Basic user in the daemon's htpasswd file
|
||||
/// (`paths::GATEWAY_HTPASSWD`). Daemon-side equivalent of `hivectl gateway
|
||||
/// create-user`: the daemon bcrypt-hashes `password` (cost 12, remapped to
|
||||
|
|
|
|||
|
|
@ -281,6 +281,41 @@ pub enum ForgeCmd {
|
|||
#[arg(long, conflicts_with = "password")]
|
||||
password_stdin: bool,
|
||||
},
|
||||
/// Show + reconcile the divergence between an agent's local applied
|
||||
/// config checkout and its forge `agent-configs/<agent>` main.
|
||||
///
|
||||
/// Always prints the diff first. `--from forge` resets the local
|
||||
/// checkout to forge main (effective on the next deploy); `--from local`
|
||||
/// is not supported yet. With no `--from`, prompts for the direction.
|
||||
ReconcileConfig {
|
||||
/// Agent whose config branches to reconcile.
|
||||
agent: String,
|
||||
/// Which side to reconcile from. Omit to be prompted after the diff.
|
||||
#[arg(long, value_enum)]
|
||||
from: Option<ReconcileFrom>,
|
||||
/// Include the full diff (not just `--stat`) in the report.
|
||||
#[arg(long)]
|
||||
verbose: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Which side to reconcile config branches from (`hivectl forge
|
||||
/// reconcile-config --from`). Maps to [`hive_host_sock::ReconcileDirection`].
|
||||
#[derive(Clone, Copy, clap::ValueEnum)]
|
||||
pub enum ReconcileFrom {
|
||||
/// Reset the local applied checkout to forge main.
|
||||
Forge,
|
||||
/// Advance forge main from local — not supported yet.
|
||||
Local,
|
||||
}
|
||||
|
||||
impl From<ReconcileFrom> for hive_host_sock::ReconcileDirection {
|
||||
fn from(from: ReconcileFrom) -> Self {
|
||||
match from {
|
||||
ReconcileFrom::Forge => Self::Forge,
|
||||
ReconcileFrom::Local => Self::Local,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@
|
|||
//! daemon (which owns the forge admin token + account-token persistence);
|
||||
//! hivectl just resolves the password client-side and relays the request.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use hive_host_sock::{HostRequest, ReconcileDirection};
|
||||
|
||||
use crate::cli::ReconcileFrom;
|
||||
use crate::util::{daemon_request, resolve_password};
|
||||
|
||||
pub(crate) async fn forge_create_user(
|
||||
|
|
@ -29,3 +32,58 @@ pub(crate) async fn forge_create_user(
|
|||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `hivectl forge reconcile-config <agent> [--from <forge|local>] [--verbose]`.
|
||||
/// Always shows the divergence first (daemon computes it read-only), then
|
||||
/// applies the chosen direction — from `--from` or an interactive prompt.
|
||||
pub(crate) async fn forge_reconcile_config(
|
||||
socket: &Path,
|
||||
agent: &str,
|
||||
from: Option<ReconcileFrom>,
|
||||
verbose: bool,
|
||||
) -> Result<()> {
|
||||
daemon_request(
|
||||
socket,
|
||||
HostRequest::ReconcileConfigStatus {
|
||||
agent: agent.to_owned(),
|
||||
verbose,
|
||||
},
|
||||
"forge",
|
||||
)
|
||||
.await?;
|
||||
let direction = match from {
|
||||
Some(f) => Some(f.into()),
|
||||
None => prompt_direction()?,
|
||||
};
|
||||
let Some(direction) = direction else {
|
||||
println!("forge: aborted — no changes made");
|
||||
return Ok(());
|
||||
};
|
||||
daemon_request(
|
||||
socket,
|
||||
HostRequest::ReconcileConfigApply {
|
||||
agent: agent.to_owned(),
|
||||
direction,
|
||||
},
|
||||
"forge",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Prompt the operator for the reconcile direction after showing the diff.
|
||||
/// Returns `None` to abort (empty / `q` / unrecognized input).
|
||||
fn prompt_direction() -> Result<Option<ReconcileDirection>> {
|
||||
print!("reconcile from [forge/local] (or q to abort): ");
|
||||
io::stdout().flush()?;
|
||||
let mut line = String::new();
|
||||
io::stdin().read_line(&mut line)?;
|
||||
match line.trim().to_ascii_lowercase().as_str() {
|
||||
"forge" | "f" => Ok(Some(ReconcileDirection::Forge)),
|
||||
"local" | "l" => Ok(Some(ReconcileDirection::Local)),
|
||||
"" | "q" | "quit" => Ok(None),
|
||||
other => {
|
||||
println!("forge: unrecognized direction {other:?} — aborting");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ use choom::choom;
|
|||
mod github;
|
||||
use github::github_set_token;
|
||||
mod forge;
|
||||
use forge::forge_create_user;
|
||||
use forge::{forge_create_user, forge_reconcile_config};
|
||||
mod agents;
|
||||
use agents::run_agents;
|
||||
mod power;
|
||||
|
|
@ -69,6 +69,11 @@ async fn main() -> Result<()> {
|
|||
password,
|
||||
password_stdin,
|
||||
} => forge_create_user(&socket, &name, password.as_deref(), password_stdin).await,
|
||||
ForgeCmd::ReconcileConfig {
|
||||
agent,
|
||||
from,
|
||||
verbose,
|
||||
} => forge_reconcile_config(&socket, &agent, from, verbose).await,
|
||||
},
|
||||
Cmd::Matrix { cmd } => run_matrix_cmd(&socket, cmd).await,
|
||||
Cmd::Github { cmd } => match cmd {
|
||||
|
|
|
|||
Loading…
Reference in a new issue