Explorar o código

feat: per-saddle git identity + ssh key storage — migration (git_name, git_email, ssh_public_key), poller and refresh persist the daemon-reported ssh_public_key, saddles page shows the key (copy button, deploy-key hint) with an inline identity edit form

clanker hai 1 mes
pai
achega
32a9554c9d

+ 1 - 0
meson.build

@@ -114,6 +114,7 @@ app_sources = files(
     'src/Data/Migrations/M0006_Capabilities.vala',
     'src/Data/Migrations/M0007_SuspendedAt.vala',
     'src/Data/Migrations/M0008_AutoApprove.vala',
+    'src/Data/Migrations/M0009_GitIdentity.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/SaddleService.vala',

+ 7 - 1
src/Data/Entities.vala

@@ -201,6 +201,9 @@ namespace ClankerSaddle.Data {
         public string status { get; set; default = "offline"; }
         public int64 current_task_id { get; set; default = 0; }
         public DateTime? allocated_at { get; set; default = null; }
+        public string? git_name { get; set; default = null; }
+        public string? git_email { get; set; default = null; }
+        public string? ssh_public_key { get; set; default = null; }
 
         public static void entity_mapping(EntityMapperBuilder<Saddle> cfg) {
             cfg.table("saddles")
@@ -210,7 +213,10 @@ namespace ClankerSaddle.Data {
                 .column<string>("host", o => o.host, (o, v) => o.host = v)
                 .column<string>("status", o => o.status, (o, v) => o.status = v)
                 .column<int64?>("current_task_id", o => o.current_task_id, (o, v) => o.current_task_id = v)
-                .column<DateTime?>("allocated_at", o => o.allocated_at, (o, v) => o.allocated_at = v);
+                .column<DateTime?>("allocated_at", o => o.allocated_at, (o, v) => o.allocated_at = v)
+                .column<string>("git_name", o => o.git_name, (o, v) => o.git_name = v)
+                .column<string>("git_email", o => o.git_email, (o, v) => o.git_email = v)
+                .column<string>("ssh_public_key", o => o.ssh_public_key, (o, v) => o.ssh_public_key = v);
         }
     }
 

+ 31 - 0
src/Data/Migrations/M0009_GitIdentity.vala

@@ -0,0 +1,31 @@
+using InvercargillSql.Migrations;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Plan 9 git identity + SSH key columns: the web stores the git
+     * identity the daemon commits as (`git_name`/`git_email`, sent in
+     * every /start as `git_identity`) and the daemon's public SSH key as
+     * reported by `GET /status` (`ssh_public_key`, empty until the
+     * daemon generates one) so it can be added as a deploy key on the
+     * git server. The private key never leaves the daemon host.
+     */
+    public class M0009_GitIdentity : Migration {
+
+        public override string name { get { return "Git identity: saddles.git_name, git_email, ssh_public_key"; } }
+        public override uint64 serial { get { return 9; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        public override void up(MigrationBuilder b) {
+            b.alter_table("saddles", (t) => {
+                t.add_column<string>("git_name");
+                t.add_column<string>("git_email");
+                t.add_column<string>("ssh_public_key");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            // Nullable additive columns; leaving them behind is harmless.
+        }
+    }
+}

+ 40 - 0
src/Services/SaddleService.vala

@@ -281,6 +281,43 @@ namespace ClankerSaddle.Services {
          * Records a /status observation into the health snapshot,
          * tolerating older daemons without the uptime/activity fields.
          */
+        /**
+         * Persists the saddle's git identity — what its daemon commits
+         * as (`git_identity` in every /start). Empty/null clears the
+         * fields so the daemon's fallback applies.
+         */
+        public async void update_git_identity(int64 id, string? git_name,
+                string? git_email) throws Error {
+            var saddle = yield get_saddle(id);
+            if (saddle == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 17,
+                    @"No saddle exists with id $(id)");
+            }
+            ((!)saddle).git_name = git_name != null && git_name.length > 0 ? git_name : null;
+            ((!)saddle).git_email = git_email != null && git_email.length > 0 ? git_email : null;
+            yield db.update_async<Saddle>((!)saddle);
+            yield pulse.bump(0, "saddle identity saved");
+        }
+
+        /**
+         * Persists the daemon's reported `ssh_public_key` onto the saddle
+         * row (the plan-9 contract: the key travels on /status, empty
+         * until the daemon generates one; the saddles page shows it for
+         * adding as a deploy key). Only writes when it changed.
+         */
+        private async void store_reported_ssh_key(Saddle saddle, Json.Object status) throws Error {
+            if (!status.has_member("ssh_public_key")) {
+                return;
+            }
+            string reported = status.get_string_member_with_default("ssh_public_key", "");
+            string stored = saddle.ssh_public_key ?? "";
+            if (reported == stored) {
+                return;
+            }
+            saddle.ssh_public_key = reported.length > 0 ? reported : null;
+            yield db.update_async<Saddle>(saddle);
+        }
+
         private void record_status_probe(int64 saddle_id, Json.Object status) {
             string state = status.has_member("state")
                 ? status.get_string_member_with_default("state", "") : "";
@@ -1127,6 +1164,8 @@ namespace ClankerSaddle.Services {
 
             var status = yield request_saddle_json(((!)saddle).host, "GET", "/status", null);
             string state = status.has_member("state") ? status.get_string_member("state") : "";
+            record_status_probe(saddle_id, status);
+            yield store_reported_ssh_key(((!)saddle), status);
 
             // A crash-resumable daemon reports `resumed: true` on the
             // first status after restoring a workspace: surface it once
@@ -1209,6 +1248,7 @@ namespace ClankerSaddle.Services {
                 var status = yield request_saddle_json(((!)saddle).host, "GET", "/status", null);
                 string state = status.has_member("state") ? status.get_string_member_with_default("state", "") : "";
                 record_status_probe(id, status);
+                yield store_reported_ssh_key(((!)saddle), status);
                 if (state == "working") {
                     // Reconcile with the web's own records: busy only when
                     // a live session truly holds this saddle.

+ 37 - 0
src/actions/SaddleActions.vala

@@ -83,6 +83,43 @@ namespace ClankerSaddle {
      * status (idle, busy or offline) — the manual counterpart of the
      * dispatcher's polling.
      */
+    /**
+     * Saves a saddle's git identity (`git_name` / `git_email`): the
+     * name/email the daemon commits as — sent with every assignment as
+     * `git_identity`; empty values clear the fields so the daemon's
+     * fallback identity applies.
+     */
+    public class UpdateSaddleIdentityAction : TypedStatumAction<SaddleActionPrivate> {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(SaddleActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/saddles");
+            }
+
+            try {
+                yield saddles.update_git_identity(((!)ctx).saddle_id,
+                    Forms.optional(request, "git_name"),
+                    Forms.optional(request, "git_email"));
+                return directives().notify("success", "Git identity saved").navigate("/saddles");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not save the identity: $(e.message)")
+                    .navigate("/saddles");
+            }
+        }
+    }
+
     public class RefreshSaddleStatusAction : TypedStatumAction<SaddleActionPrivate> {
 
         private SaddleService saddles = inject<SaddleService>();

+ 8 - 0
src/entrypoints/SaddlesEntrypoint.vala

@@ -38,6 +38,10 @@ namespace ClankerSaddle {
         public string daemon_state { get; set; default = ""; }
         public string uptime_display { get; set; default = ""; }
         public string last_contact_display { get; set; default = ""; }
+        public string git_name { get; set; default = ""; }
+        public string git_email { get; set; default = ""; }
+        public string ssh_public_key { get; set; default = ""; }
+        public Statum.Model.ActionDto? update_identity { get; set; }
         public int64 current_task_id { get; set; }
         public string current_task_title { get; set; default = ""; }
         public string allocated_display { get; set; default = ""; }
@@ -160,6 +164,10 @@ namespace ClankerSaddle {
                     daemon_state = daemon_state,
                     uptime_display = uptime > 0 ? relative_time(uptime * 1000) : "",
                     last_contact_display = last_contact,
+                    git_name = saddle.git_name ?? "",
+                    git_email = saddle.git_email ?? "",
+                    ssh_public_key = saddle.ssh_public_key ?? "",
+                    update_identity = action_registry.author_private<UpdateSaddleIdentityAction, SaddleActionPrivate>(sealed),
                     current_task_id = saddle.current_task_id,
                     allocated_display = saddle.allocated_at != null
                         ? ((!)saddle.allocated_at).format("%Y-%m-%d %H:%M") : "",

+ 2 - 0
src/main.vala

@@ -34,6 +34,7 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0006_Capabilities>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0007_SuspendedAt>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0008_AutoApprove>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0009_GitIdentity>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -105,6 +106,7 @@ int main(string[] args) {
         statum.action<RegisterSaddleAction>();
         statum.action<UnregisterSaddleAction>();
         statum.action<RefreshSaddleStatusAction>();
+        statum.action<UpdateSaddleIdentityAction>();
         statum.action<CreateRuleAction>();
         statum.action<UpdateRuleAction>();
         statum.action<DeleteRuleAction>();

+ 14 - 0
src/pages/app.css

@@ -150,6 +150,20 @@ summary { cursor: pointer; font-weight: 600; }
     vertical-align: middle;
 }
 
+/* The saddle's SSH public key display */
+.ssh-key {
+    background: #111827;
+    border: 1px solid #374151;
+    border-radius: 4px;
+    font-size: 0.75rem;
+    margin: 0.5em 0;
+    max-height: 7em;
+    overflow: auto;
+    padding: 0.5em 0.75em;
+    white-space: pre-wrap;
+    word-break: break-all;
+}
+
 /* Health badges on the saddles page */
 .badge-health-healthy {
     background: #14532d;

+ 23 - 0
src/pages/saddles.html

@@ -37,6 +37,29 @@
                         <span class="muted" stm-text="'contacted ' + saddle.last_contact_display + ' ago'">contacted</span></p>
                     <p stm-if="saddle.token.length > 0" class="muted">API token:
                         <code stm-text="saddle.token">token</code></p>
+                    <details stm-if="saddle.ssh_public_key.length > 0">
+                        <summary>SSH public key</summary>
+                        <p class="muted">Add this as a <strong>deploy key</strong> on the git server
+                            (with write access) so this saddle can clone and push.</p>
+                        <pre class="ssh-key"><code stm-text="saddle.ssh_public_key">key</code></pre>
+                        <button type="button"
+                                stm-attribute.data-clipboard="saddle.ssh_public_key"
+                                onclick="navigator.clipboard.writeText(this.getAttribute('data-clipboard')).then(function () { this.textContent = 'Copied'; }.bind(this))">Copy key</button>
+                    </details>
+                    <p stm-if="saddle.ssh_public_key.length == 0" class="muted">No SSH key reported yet —
+                        it appears here once the daemon generates one.</p>
+                    <details>
+                        <summary>Git identity</summary>
+                        <form stm-action="saddle.update_identity" class="inline">
+                            <label>Committed as</label>
+                            <input type="text" name="git_name" stm-attribute.value="saddle.git_name"
+                                   placeholder="saddle-1">
+                            <input type="text" name="git_email" stm-attribute.value="saddle.git_email"
+                                   placeholder="saddle-1@example.net">
+                            <button type="submit">Save</button>
+                            <span class="muted">Empty fields fall back to the daemon's default identity.</span>
+                        </form>
+                    </details>
                     <p stm-if="saddle.capabilities.length > 0">Capabilities:
                         <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
                             <span class="badge"><span stm-text="capability.name">tag</span>

+ 64 - 1
test/web_stack_test.py

@@ -226,6 +226,7 @@ class FakeSaddle:
         self.zombie_409 = 0              # /start 409s once per unit (then decrements)
         self.conflict_running = 0        # while > 0, /start persistently 409s with this id
         self.hold_state = False          # freeze /status lifecycle transitions
+        self.ssh_public_key = ""         # reported on /status (plan-9 contract)
         self.started_mono = time.monotonic()
         self.freeze_activity = False     # hold last_activity_epoch still
 
@@ -382,7 +383,8 @@ class FakeSaddle:
             if self.path == "/status":
                 response = {"state": self.daemon.state, "summary": self.daemon.summary,
                             "uptime_seconds": int(time.monotonic() - self.daemon.started_mono),
-                            "last_activity_epoch": self.daemon.last_activity_epoch}
+                            "last_activity_epoch": self.daemon.last_activity_epoch,
+                            "ssh_public_key": self.daemon.ssh_public_key}
                 if self.daemon.resumed_pending:
                     response["resumed"] = True
                     self.daemon.resumed_pending = False
@@ -2258,6 +2260,66 @@ def main():
         check(wait_task_done(auth, lossless, timeout=60) is not None,
               "lossless task completes")
 
+        print("== R9 B1/B2: git identity + ssh key ==")
+        # The key travels on /status into the saddle row and the page view.
+        fake.ssh_public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1teStKey saddle-1@fedora"
+        fake.state = "idle"
+        fake_card = saddle_card("fake-saddle")
+        check(fake_card.get("ssh_public_key", "") == "",
+              "no key shown before the daemon reports one")
+        act(auth, fake_card["refresh"], {})
+        check(wait_for(lambda: "AAAAC3NzaC1lZDI1teStKey" in
+                       saddle_card("fake-saddle").get("ssh_public_key", "") or False,
+                       timeout=30, what="key lands on the row") is not None,
+              "reported ssh_public_key persists onto the saddle row")
+        conn = db()
+        row_key = conn.execute("select ssh_public_key from saddles where name = 'fake-saddle'"
+                               ).fetchone()[0]
+        conn.close()
+        check(row_key == fake.ssh_public_key,
+              "the key is stored verbatim in the database")
+
+        # Identity edit flows through the UI into the /start payload.
+        fake_card = saddle_card("fake-saddle")
+        act(auth, fake_card["update_identity"],
+            {"git_name": "clanker-bot", "git_email": "bot@unitatem.net"})
+        fake_card = saddle_card("fake-saddle")
+        check(fake_card.get("git_name") == "clanker-bot"
+              and fake_card.get("git_email") == "bot@unitatem.net",
+              "inline edit saves the git identity")
+
+        pushes_before = len(fake.start_payloads)
+        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+            "project_id": "1",
+            "title": "Identity task",
+            "priority": "2",
+        })
+        identity_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                             if t["title"] == "Identity task"][0])
+        check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == identity_task
+                                   for p in fake.start_payloads) or False,
+                       timeout=60, what="identity task pushed") is not None,
+              "identity task dispatches")
+        payload = [p for p in fake.start_payloads
+                   if int(p.get("task", {}).get("id") or 0) == identity_task][-1]
+        identity = payload.get("git_identity") or {}
+        check(identity.get("name") == "clanker-bot"
+              and identity.get("email") == "bot@unitatem.net",
+              "/start carries the saddle's git_identity")
+
+        # Earlier pushes (identity unset) carry no git_identity member.
+        check(all("git_identity" not in p for p in fake.start_payloads[:-1]),
+              "unset identity omits the member (daemon fallback applies)")
+
+        # Clearing the identity omits it from later pushes.
+        fake_card = saddle_card("fake-saddle")
+        act(auth, fake_card["update_identity"], {"git_name": "", "git_email": ""})
+        check(saddle_card("fake-saddle").get("git_name", "") == "",
+              "clearing the identity empties the row")
+        check(wait_task_done(auth, identity_task, timeout=60) is not None,
+              "identity task completes")
+
+
         # The daemon vanishing mid-task: three refused polls → offline,
         # task requeued with a bot comment, UI reflects.
         act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
@@ -2302,6 +2364,7 @@ def main():
         check(row.get("unreachable") is True,
               "queue row shows the unreachable state in the window")
 
+
     finally:
         # Exit-code diagnostic: distinguishes a crashed app (negative
         # returncode, e.g. -11 SIGSEGV) from a clean shutdown. When