a73x

401f3162

feat(server): a VM says which key eitri put in it

a73x   2026-08-06 09:12

Commit message
feat(server): a VM says which key eitri put in it

A VM carries a description of the authorized key eitri installed in it: the key
type, its SHA256 fingerprint in OpenSSH's own spelling, and its comment. The key
itself stays write-only, like everything else a create accepts — a description
of what eitri did is a different thing, and safe to read.

The description is taken at create, before the cloud-init merge, and that
ordering is load-bearing. A create supplying both cloud_init and a key has the
key folded into the cloud-init document and ssh_authorized_key cleared, so the
column holds nothing for exactly the guests whose owners are most likely to ask
which key is in them.

It is stored rather than derived on read: the SSE snapshot marshals every VM on
a one-second tick, and a key's fingerprint cannot change.

It describes without validating. Create accepts any single-line key, because the
guest's sshd is what decides which keys it honours, so a key that cannot be
parsed reports what it claims to be and no fingerprint. An empty fingerprint
means unreadable, which is a different statement from no key at all.

It covers what eitri injected and nothing else. A key inside a user's own
cloud-init is theirs: eitri did not install it and does not claim to know it.

docs/openapi.json
Old New
@@ -329,6 +329,25 @@
329 ], 329 ],
330 "type": "object" 330 "type": "object"
331 }, 331 },
332 "InjectedKey": {
333 "properties": {
334 "comment": {
335 "type": "string"
336 },
337 "fingerprint": {
338 "type": "string"
339 },
340 "type": {
341 "type": "string"
342 }
343 },
344 "required": [
345 "comment",
346 "fingerprint",
347 "type"
348 ],
349 "type": "object"
350 },
332 "Me": { 351 "Me": {
333 "properties": { 352 "properties": {
334 "email": { 353 "email": {
@@ -550,6 +569,16 @@
550 "image_url": { 569 "image_url": {
551 "type": "string" 570 "type": "string"
552 }, 571 },
572 "injected_key": {
573 "anyOf": [
574 {
575 "$ref": "#/components/schemas/InjectedKey"
576 },
577 {
578 "type": "null"
579 }
580 ]
581 },
553 "last_error": { 582 "last_error": {
554 "type": "string" 583 "type": "string"
555 }, 584 },
internal/server/api/api.go
Old New
@@ -542,6 +542,7 @@ func toVMResponse(vm store.VM, actualPower, phase string, destroyAt int64) types
542 Phase: phase, 542 Phase: phase,
543 DestroyAt: destroyAt, 543 DestroyAt: destroyAt,
544 Lifecycle: deriveLifecycle(vm, actualPower, phase), 544 Lifecycle: deriveLifecycle(vm, actualPower, phase),
545 InjectedKey: injectedKey(vm),
545 } 546 }
546 } 547 }
547 548
@@ -766,6 +767,11 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
766 // with a different default user gets the key there. The key is embedded as a 767 // with a different default user gets the key there. The key is embedded as a
767 // YAML scalar node, so it cannot inject structure (validateCreateVM's 768 // YAML scalar node, so it cannot inject structure (validateCreateVM's
768 // single-line check is belt-and-suspenders, not the load-bearing guard). 769 // single-line check is belt-and-suspenders, not the load-bearing guard).
770 // Describe the key BEFORE the merge below clears the field. The record of
771 // what eitri installed must survive that clearing — it is the whole reason
772 // the console can answer "which key did you put in this VM".
773 keyType, keyFP, keyComment := describeKey(req.SSHAuthorizedKey)
774
769 if req.CloudInit != "" && req.SSHAuthorizedKey != "" { 775 if req.CloudInit != "" && req.SSHAuthorizedKey != "" {
770 merged, err := cloudinit.AddSSHKey(req.CloudInit, req.SSHAuthorizedKey) 776 merged, err := cloudinit.AddSSHKey(req.CloudInit, req.SSHAuthorizedKey)
771 if err != nil { 777 if err != nil {
@@ -787,11 +793,15 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
787 ImageSHA256: req.ImageSHA256, 793 ImageSHA256: req.ImageSHA256,
788 CloudInit: req.CloudInit, 794 CloudInit: req.CloudInit,
789 SSHAuthorizedKey: req.SSHAuthorizedKey, 795 SSHAuthorizedKey: req.SSHAuthorizedKey,
790 VCPUs: req.VCPUs, 796
791 MemMB: req.MemMB, 797 InjectedKeyType: keyType,
792 DiskGB: req.DiskGB, 798 InjectedKeyFP: keyFP,
793 Persistent: req.Persistent, 799 InjectedKeyComment: keyComment,
794 PowerState: req.PowerState, 800 VCPUs: req.VCPUs,
801 MemMB: req.MemMB,
802 DiskGB: req.DiskGB,
803 Persistent: req.Persistent,
804 PowerState: req.PowerState,
795 } 805 }
796 806
797 // When the jump gate is enabled, mint a persistent per-VM host key + CA-signed 807 // When the jump gate is enabled, mint a persistent per-VM host key + CA-signed
internal/server/api/api_test.go
Old New
@@ -1055,3 +1055,80 @@ func TestVMResponseSurfacesTeardownDestroyDeadline(t *testing.T) {
1055 assert.Equal(t, float64(0), byID[healthyID]["destroy_at"], 1055 assert.Equal(t, float64(0), byID[healthyID]["destroy_at"],
1056 "a VM not scheduled for destruction reports destroy_at == 0") 1056 "a VM not scheduled for destruction reports destroy_at == 0")
1057 } 1057 }
1058
1059 // TestVMReportsTheKeyEitriInjected pins that the console can answer "which key
1060 // did you put in this VM". The subtle case is the second one: when a create
1061 // supplies BOTH cloud_init and a key, the key is merged into the cloud-init
1062 // document and ssh_authorized_key is cleared — so the column alone forgets a
1063 // key eitri definitely installed.
1064 //
1065 // It also pins the boundary of the claim. eitri reports what IT injected; a key
1066 // a user buries in their own cloud_init is theirs, and is deliberately invisible
1067 // here rather than half-tracked.
1068 func TestVMReportsTheKeyEitriInjected(t *testing.T) {
1069 const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0uJyEWzAFdAelGXHwoFgSRL+py8ZMonqWw+M4wj6HG alex@laptop"
1070 ts, _, _ := testServer(t)
1071 out := enroll(t, ts)
1072
1073 // vmInjectedKey creates a VM and returns its injected_key from the API.
1074 vmInjectedKey := func(t *testing.T, name string, body map[string]any) map[string]any {
1075 t.Helper()
1076 body["host_id"], body["name"] = out["host_id"], name
1077 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, body)
1078 require.Equal(t, 201, resp.StatusCode)
1079 var created map[string]string
1080 json.NewDecoder(resp.Body).Decode(&created)
1081
1082 resp = do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil)
1083 require.Equal(t, 200, resp.StatusCode)
1084 for _, v := range decodeJSONKeys(t, resp) {
1085 if v["id"] == created["id"] {
1086 require.Contains(t, v, "injected_key", "the VM response must carry injected_key")
1087 if v["injected_key"] == nil {
1088 return nil
1089 }
1090 return v["injected_key"].(map[string]any)
1091 }
1092 }
1093 t.Fatal("created VM missing from the list")
1094 return nil
1095 }
1096
1097 t.Run("key alone", func(t *testing.T) {
1098 got := vmInjectedKey(t, "keyed", map[string]any{"ssh_authorized_key": key})
1099 require.NotNil(t, got)
1100 assert.Equal(t, "ssh-ed25519", got["type"])
1101 assert.Equal(t, "alex@laptop", got["comment"])
1102 assert.Contains(t, got["fingerprint"], "SHA256:")
1103 })
1104
1105 // The merge path: ssh_authorized_key is cleared once folded into cloud-init,
1106 // so this is exactly where a naive read of the column reports nothing.
1107 t.Run("key merged into cloud-init is still reported", func(t *testing.T) {
1108 got := vmInjectedKey(t, "keyed-with-ci", map[string]any{
1109 "ssh_authorized_key": key,
1110 "cloud_init": "#cloud-config\npackages:\n - htop\n",
1111 })
1112 require.NotNil(t, got, "a key merged into cloud-init was still injected by eitri")
1113 assert.Equal(t, "alex@laptop", got["comment"])
1114 })
1115
1116 t.Run("no key injected reports nothing", func(t *testing.T) {
1117 assert.Nil(t, vmInjectedKey(t, "bare", map[string]any{}))
1118 })
1119
1120 // A key eitri never installed is not eitri's to report.
1121 t.Run("a key hidden in the user's own cloud-init is not claimed", func(t *testing.T) {
1122 assert.Nil(t, vmInjectedKey(t, "byo", map[string]any{
1123 "cloud_init": "#cloud-config\nusers:\n - name: me\n ssh_authorized_keys:\n - " + key + "\n",
1124 }))
1125 })
1126
1127 // The description is derived and readable; the key itself stays write-only.
1128 t.Run("the raw key is still never echoed", func(t *testing.T) {
1129 resp := do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil)
1130 for _, v := range decodeJSONKeys(t, resp) {
1131 assert.NotContains(t, v, "ssh_authorized_key")
1132 }
1133 })
1134 }
internal/server/api/injectedkey.go
Old New
@@ -0,0 +1,72 @@
1 package api
2
3 import (
4 "strings"
5
6 "github.com/a73x/eitri/internal/server/api/types"
7 "github.com/a73x/eitri/internal/server/store"
8 "golang.org/x/crypto/ssh"
9 )
10
11 // maxKeyCommentLen bounds the comment kept from a user-supplied key. It is
12 // free text that ends up rendered in the console, and an authorized_keys
13 // comment is conventionally a short "user@host" — anything longer is either a
14 // mistake or an attempt to fill a column.
15 const maxKeyCommentLen = 128
16
17 // describeKey summarises the authorized key eitri is about to install, for the
18 // record kept on the VM row. It returns the key type, its SHA256 fingerprint in
19 // OpenSSH's own spelling, and the key's comment.
20 //
21 // It DESCRIBES rather than validates. Create deliberately accepts any
22 // single-line key — the guest's sshd is the thing that decides what it will
23 // honour, and refusing a key here on cryptographic grounds would be eitri
24 // second-guessing it. So a key that cannot be parsed still yields whatever can
25 // be said about it: the leading token as its type, and no fingerprint. An empty
26 // fingerprint is the signal that the key was unreadable, not that none exists.
27 //
28 // A key eitri never installed returns three empty strings, which is a different
29 // statement entirely: nothing was injected.
30 func describeKey(line string) (keyType, fingerprint, comment string) {
31 line = strings.TrimSpace(line)
32 if line == "" {
33 return "", "", ""
34 }
35 pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
36 if err != nil {
37 // Unreadable: say what it claims to be and nothing more. The first
38 // field of an authorized_keys line is its type by convention, and a
39 // claim is still worth showing next to "we could not read this".
40 return firstField(line), "", ""
41 }
42 if len(comment) > maxKeyCommentLen {
43 comment = comment[:maxKeyCommentLen]
44 }
45 return pub.Type(), ssh.FingerprintSHA256(pub), comment
46 }
47
48 // firstField returns the first whitespace-separated token of s, bounded so an
49 // unparseable line cannot contribute an unbounded "type".
50 func firstField(s string) string {
51 if i := strings.IndexAny(s, " \t"); i >= 0 {
52 s = s[:i]
53 }
54 if len(s) > 32 {
55 s = s[:32]
56 }
57 return s
58 }
59
60 // injectedKey renders a VM row's recorded key for the wire, or nil when eitri
61 // installed none. It is derived and read-only: the key itself stays write-only,
62 // like every other field a create accepts and no response echoes.
63 func injectedKey(vm store.VM) *types.InjectedKey {
64 if vm.InjectedKeyType == "" && vm.InjectedKeyFP == "" {
65 return nil
66 }
67 return &types.InjectedKey{
68 Type: vm.InjectedKeyType,
69 Fingerprint: vm.InjectedKeyFP,
70 Comment: vm.InjectedKeyComment,
71 }
72 }
internal/server/api/injectedkey_test.go
Old New
@@ -0,0 +1,49 @@
1 package api
2
3 import (
4 "strings"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 )
9
10 // realKey is a genuine ed25519 authorized_keys line, so the fingerprint below
11 // is the one OpenSSH itself computes (`ssh-keygen -lf`).
12 const realKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0uJyEWzAFdAelGXHwoFgSRL+py8ZMonqWw+M4wj6HG alex@laptop"
13
14 func TestDescribeKey(t *testing.T) {
15 t.Run("a real key is identified by fingerprint", func(t *testing.T) {
16 typ, fp, comment := describeKey(realKey)
17 assert.Equal(t, "ssh-ed25519", typ)
18 assert.True(t, strings.HasPrefix(fp, "SHA256:"), "fingerprint must be OpenSSH-spelled, got %q", fp)
19 assert.Equal(t, "alex@laptop", comment)
20 })
21
22 t.Run("no key injected is three empties", func(t *testing.T) {
23 typ, fp, comment := describeKey("")
24 assert.Empty(t, typ)
25 assert.Empty(t, fp)
26 assert.Empty(t, comment)
27 })
28
29 // Create deliberately accepts any single-line key — the guest's sshd decides
30 // what it honours — so an unreadable one must still be described, not
31 // dropped. An empty fingerprint is the signal, and it must be
32 // distinguishable from "no key at all".
33 t.Run("an unreadable key still says what it claims to be", func(t *testing.T) {
34 typ, fp, comment := describeKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@host")
35 assert.Equal(t, "ssh-ed25519", typ)
36 assert.Empty(t, fp, "an unreadable key has no fingerprint")
37 assert.Empty(t, comment)
38 })
39
40 t.Run("bounded against a hostile line", func(t *testing.T) {
41 typ, _, _ := describeKey(strings.Repeat("z", 5000))
42 assert.LessOrEqual(t, len(typ), 32, "an unparseable type must be bounded")
43
44 long := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0uJyEWzAFdAelGXHwoFgSRL+py8ZMonqWw+M4wj6HG " +
45 strings.Repeat("c", 5000)
46 _, _, comment := describeKey(long)
47 assert.LessOrEqual(t, len(comment), maxKeyCommentLen)
48 })
49 }
internal/server/api/testdata/snapshot.golden.json
Old New
@@ -63,7 +63,12 @@
63 "actual_power": "stopped", 63 "actual_power": "stopped",
64 "phase": "creating", 64 "phase": "creating",
65 "destroy_at": 1785153600, 65 "destroy_at": 1785153600,
66 "lifecycle": "deleting" 66 "lifecycle": "deleting",
67 "injected_key": {
68 "type": "ssh-ed25519",
69 "fingerprint": "SHA256:0000000000000000000000000000000000000000000",
70 "comment": "alex@laptop"
71 }
67 } 72 }
68 ], 73 ],
69 "server_version": "v0.0.1-test", 74 "server_version": "v0.0.1-test",
internal/server/api/testdata/vm.golden.json
Old New
@@ -16,5 +16,10 @@
16 "actual_power": "stopped", 16 "actual_power": "stopped",
17 "phase": "creating", 17 "phase": "creating",
18 "destroy_at": 1785153600, 18 "destroy_at": 1785153600,
19 "lifecycle": "deleting" 19 "lifecycle": "deleting",
20 "injected_key": {
21 "type": "ssh-ed25519",
22 "fingerprint": "SHA256:0000000000000000000000000000000000000000000",
23 "comment": "alex@laptop"
24 }
20 } 25 }
internal/server/api/types/types.go
Old New
@@ -114,6 +114,24 @@ type VM struct {
114 // loops (reconciler, reaper, agent) read and write the underlying axes, 114 // loops (reconciler, reaper, agent) read and write the underlying axes,
115 // never this field. Values: creating | ready | stopped | failed | deleting. 115 // never this field. Values: creating | ready | stopped | failed | deleting.
116 Lifecycle string `json:"lifecycle"` 116 Lifecycle string `json:"lifecycle"`
117 // InjectedKey describes the authorized key EITRI installed in this guest at
118 // create, or null when it installed none. It is a description, not the key:
119 // ssh_authorized_key stays write-only like everything else a create accepts.
120 //
121 // It covers only what eitri put there. A key hidden inside a user's own
122 // cloud_init is invisible here by design — eitri did not install it and does
123 // not claim to know about it.
124 InjectedKey *InjectedKey `json:"injected_key"`
125 }
126
127 // InjectedKey identifies one authorized key by its OpenSSH fingerprint, the way
128 // `ssh-add -l` does. An empty Fingerprint means the key could not be read —
129 // create accepts any single-line key, since the guest's sshd is what decides
130 // what it honours.
131 type InjectedKey struct {
132 Type string `json:"type"`
133 Fingerprint string `json:"fingerprint"`
134 Comment string `json:"comment"`
117 } 135 }
118 136
119 // StateSnapshot is the full fleet state pushed as each `event: state` frame 137 // StateSnapshot is the full fleet state pushed as each `event: state` frame
internal/server/api/wire_golden_test.go
Old New
@@ -107,6 +107,11 @@ func TestWireGolden(t *testing.T) {
107 Phase: "creating", 107 Phase: "creating",
108 DestroyAt: 1785153600, 108 DestroyAt: 1785153600,
109 Lifecycle: "deleting", 109 Lifecycle: "deleting",
110 InjectedKey: &types.InjectedKey{
111 Type: "ssh-ed25519",
112 Fingerprint: "SHA256:0000000000000000000000000000000000000000000",
113 Comment: "alex@laptop",
114 },
110 } 115 }
111 goldenCheck(t, "vm", vm) 116 goldenCheck(t, "vm", vm)
112 117
internal/server/store/store.go
Old New
@@ -77,6 +77,14 @@ type VM struct {
77 // never returned in vmResponse and never logged. SSHHostCert is the matching 77 // never returned in vmResponse and never logged. SSHHostCert is the matching
78 // CA-signed host cert (authorized_keys form); public, but grouped here. 78 // CA-signed host cert (authorized_keys form); public, but grouped here.
79 SSHHostKey, SSHHostCert string 79 SSHHostKey, SSHHostCert string
80 // InjectedKey* describe the authorized key eitri installed at create: its
81 // type, SHA256 fingerprint and comment. They are a RECORD of what eitri
82 // did, not an input to it — SSHAuthorizedKey is cleared when the key is
83 // merged into user-supplied cloud-init, and these are not. Keys a user
84 // hides inside their own cloud_init are their business and are deliberately
85 // not tracked. Empty on rows created before the columns existed, which is
86 // honest: the record was never taken.
87 InjectedKeyType, InjectedKeyFP, InjectedKeyComment string
80 // The VM's reachable address is AssignedIP (the agent-reported bridge IP). 88 // The VM's reachable address is AssignedIP (the agent-reported bridge IP).
81 CreatedAt time.Time 89 CreatedAt time.Time
82 DeletedAt *time.Time 90 DeletedAt *time.Time
@@ -266,6 +274,15 @@ func Open(path, cidrPool string) (*Store, error) {
266 // never applies to new rows). 274 // never applies to new rows).
267 {"audit_log", "tenant", "TEXT NOT NULL DEFAULT 'default'"}, 275 {"audit_log", "tenant", "TEXT NOT NULL DEFAULT 'default'"},
268 {"revoked_ssh_certs", "tenant", "TEXT NOT NULL DEFAULT 'default'"}, 276 {"revoked_ssh_certs", "tenant", "TEXT NOT NULL DEFAULT 'default'"},
277 // What eitri put in the guest's authorized_keys, described rather than
278 // copied: type, SHA256 fingerprint and comment. Derived ONCE at create
279 // (see api.describeKey) because the SSE snapshot marshals every VM on a
280 // 1s tick and parsing a key per read would be work repeated forever.
281 // Rows that predate these columns show no key, which is honest — the
282 // record was never taken.
283 {"vms", "injected_key_type", "TEXT NOT NULL DEFAULT ''"},
284 {"vms", "injected_key_fp", "TEXT NOT NULL DEFAULT ''"},
285 {"vms", "injected_key_comment", "TEXT NOT NULL DEFAULT ''"},
269 } { 286 } {
270 if err := ensureColumn(db, c.table, c.column, c.decl); err != nil { 287 if err := ensureColumn(db, c.table, c.column, c.decl); err != nil {
271 db.Close() 288 db.Close()
@@ -572,11 +589,13 @@ func (s *Store) CreateVM(vm VM) error {
572 _, err = tx.Exec( 589 _, err = tx.Exec(
573 `INSERT INTO vms(id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, 590 `INSERT INTO vms(id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key,
574 ssh_host_key, ssh_host_cert, 591 ssh_host_key, ssh_host_cert,
592 injected_key_type, injected_key_fp, injected_key_comment,
575 vcpus, mem_mb, disk_gb, persistent, power_state, created_at) 593 vcpus, mem_mb, disk_gb, persistent, power_state, created_at)
576 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, 594 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
577 vm.ID, vm.HostID, vm.Name, vm.Tenant, vm.ImageURL, vm.ImageSHA256, 595 vm.ID, vm.HostID, vm.Name, vm.Tenant, vm.ImageURL, vm.ImageSHA256,
578 vm.CloudInit, vm.SSHAuthorizedKey, 596 vm.CloudInit, vm.SSHAuthorizedKey,
579 vm.SSHHostKey, vm.SSHHostCert, 597 vm.SSHHostKey, vm.SSHHostCert,
598 vm.InjectedKeyType, vm.InjectedKeyFP, vm.InjectedKeyComment,
580 vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState, 599 vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState,
581 now.Format(time.RFC3339), 600 now.Format(time.RFC3339),
582 ) 601 )
@@ -1072,6 +1091,7 @@ func (s *Store) RecordVMStatus(id, status, lastErr, ip string) (string, error) {
1072 // of lockstep. 1091 // of lockstep.
1073 const vmColumns = `id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, 1092 const vmColumns = `id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key,
1074 ssh_host_key, ssh_host_cert, 1093 ssh_host_key, ssh_host_cert,
1094 injected_key_type, injected_key_fp, injected_key_comment,
1075 vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, 1095 vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip,
1076 created_at, deleted_at` 1096 created_at, deleted_at`
1077 1097
@@ -1084,6 +1104,7 @@ func scanVM(rows *sql.Rows) (VM, error) {
1084 err := rows.Scan( 1104 err := rows.Scan(
1085 &vm.ID, &vm.HostID, &vm.Name, &vm.Tenant, &vm.ImageURL, &vm.ImageSHA256, 1105 &vm.ID, &vm.HostID, &vm.Name, &vm.Tenant, &vm.ImageURL, &vm.ImageSHA256,
1086 &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostKey, &vm.SSHHostCert, 1106 &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostKey, &vm.SSHHostCert,
1107 &vm.InjectedKeyType, &vm.InjectedKeyFP, &vm.InjectedKeyComment,
1087 &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent, 1108 &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent,
1088 &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP, 1109 &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP,
1089 &createdAt, &deletedAt, 1110 &createdAt, &deletedAt,
web/src/lib/api-types.ts
Old New
@@ -1314,6 +1314,11 @@ export interface components {
1314 status: string; 1314 status: string;
1315 virt: string; 1315 virt: string;
1316 }; 1316 };
1317 InjectedKey: {
1318 comment: string;
1319 fingerprint: string;
1320 type: string;
1321 };
1317 Me: { 1322 Me: {
1318 email: string; 1323 email: string;
1319 tenant: string; 1324 tenant: string;
@@ -1377,6 +1382,7 @@ export interface components {
1377 host_id: string; 1382 host_id: string;
1378 id: string; 1383 id: string;
1379 image_url: string; 1384 image_url: string;
1385 injected_key?: components["schemas"]["InjectedKey"] | null;
1380 last_error: string; 1386 last_error: string;
1381 lifecycle: string; 1387 lifecycle: string;
1382 mem_mb: number; 1388 mem_mb: number;
web/src/routes/vms/[id]/+page.svelte
Old New
@@ -122,6 +122,25 @@
122 <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr> 122 <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr>
123 <tr><th>Persistent</th><td>{vm.persistent}</td></tr> 123 <tr><th>Persistent</th><td>{vm.persistent}</td></tr>
124 <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr> 124 <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr>
125 <tr>
126 <th>Injected key</th>
127 <td>
128 {#if !vm.injected_key}
129 <span class="hint">none—eitri installed no key in this guest</span>
130 {:else if vm.injected_key.fingerprint}
131 <code class="wrap">{vm.injected_key.fingerprint}</code>
132 <span class="hint"
133 >{vm.injected_key.type}{#if vm.injected_key.comment}, {vm.injected_key
134 .comment}{/if}</span
135 >
136 {:else}
137 <span class="hint"
138 >{vm.injected_key.type}—unreadable, so no fingerprint. The guest's sshd decides
139 whether it works.</span
140 >
141 {/if}
142 </td>
143 </tr>
125 {#if vm.last_error}<tr><th>Last error</th><td class="err">{vm.last_error}</td></tr>{/if} 144 {#if vm.last_error}<tr><th>Last error</th><td class="err">{vm.last_error}</td></tr>{/if}
126 <tr><th>Created</th><td>{vm.created_at}</td></tr> 145 <tr><th>Created</th><td>{vm.created_at}</td></tr>
127 </tbody> 146 </tbody>