slice 10A: activate the control envelope (Down channel) + hub-backed desired provider (v0.15.0)
The control envelope becomes live: the agent caches the hub's desired-state +
generation and re-fetches GET /hosts/{id}/desired-state only when the
generation advances. A new internal/desired Syncer maps the wire shape into a
reconcile.CachingProvider feeding the engine; benign deltas reconcile, an
explicit guest decommission is gated pending_signature (exec is 10B). Adds the
DesiredStateResponse/WireDesiredState wire types + Client.FetchDesiredState +
the loop EnvelopeObserver seam. Cross-repo golden (envelope + desired-state)
byte-identical with the hub.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+38
-3
@@ -25,6 +25,7 @@ const reportPath = "/api/v1/host-report"
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
hostID string // for the slice-10A desired-state/jobs paths (/hosts/{hostID}/…)
|
||||
hc *http.Client
|
||||
logger *slog.Logger
|
||||
}
|
||||
@@ -51,12 +52,12 @@ func NewClient(cfg config.HubConfig, logger *slog.Logger) (*Client, error) {
|
||||
Timeout: time.Duration(cfg.TimeoutSeconds) * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
||||
}
|
||||
return newClient(cfg.URL, cfg.APIKey, hc, logger), nil
|
||||
return newClient(cfg.URL, cfg.APIKey, cfg.HostID, hc, logger), nil
|
||||
}
|
||||
|
||||
// newClient is the shared constructor (tests inject a mock-transport *http.Client).
|
||||
func newClient(baseURL, apiKey string, hc *http.Client, logger *slog.Logger) *Client {
|
||||
return &Client{baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, hc: hc, logger: logger}
|
||||
func newClient(baseURL, apiKey, hostID string, hc *http.Client, logger *slog.Logger) *Client {
|
||||
return &Client{baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, hostID: hostID, hc: hc, logger: logger}
|
||||
}
|
||||
|
||||
// TransportError is a network/connection failure (no HTTP response). It never
|
||||
@@ -109,6 +110,40 @@ func (c *Client) Report(ctx context.Context, r *HostReport) (*ControlEnvelope, e
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchDesiredState GETs the host's authoritative desired-state (slice 10A — the "Down" channel's
|
||||
// heavy payload). The agent calls this ONLY when the heartbeat envelope's DesiredGeneration has
|
||||
// advanced past its cached one (the heartbeat stays light; the state moves on change). It is
|
||||
// self-scoped server-side: the per-host key only ever reads ITS OWN host (the client uses its
|
||||
// configured hostID). Errors are typed (transport vs HTTP) and never include the bearer token.
|
||||
func (c *Client) FetchDesiredState(ctx context.Context) (*DesiredStateResponse, error) {
|
||||
if c.hostID == "" {
|
||||
return nil, fmt.Errorf("hub: FetchDesiredState requires a configured host_id")
|
||||
}
|
||||
url := c.baseURL + "/api/v1/hosts/" + c.hostID + "/desired-state"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hub: building desired-state request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, &TransportError{Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, &HTTPError{StatusCode: resp.StatusCode, BodyTail: tail(raw, 256)}
|
||||
}
|
||||
var out DesiredStateResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("hub: decoding desired-state: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func tail(b []byte, max int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > max {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FetchDesiredState GETs the SELF-SCOPED path with the bearer token and decodes the response.
|
||||
func TestFetchDesiredState_PathAuthAndDecode(t *testing.T) {
|
||||
var gotPath, gotAuth, gotMethod string
|
||||
c := testClient(func(r *http.Request) (*http.Response, error) {
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotMethod = r.Method
|
||||
return httpResp(200, `{"generation":4,"desired_state":{"guests":[
|
||||
{"vmid":100,"run":"running"},
|
||||
{"vmid":200,"decommission":true}
|
||||
],"restore_directive":{"mode":"guest_loss","archive":"local:backup/x","vmid":100}}}`), nil
|
||||
})
|
||||
|
||||
resp, err := c.FetchDesiredState(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDesiredState: %v", err)
|
||||
}
|
||||
// Self-scoped: the client only ever fetches ITS OWN host (the configured host_id).
|
||||
if gotMethod != http.MethodGet || gotPath != "/api/v1/hosts/demo-host-01/desired-state" {
|
||||
t.Errorf("request = %s %s, want GET /api/v1/hosts/demo-host-01/desired-state", gotMethod, gotPath)
|
||||
}
|
||||
if gotAuth != "Bearer super-secret-bearer-key" {
|
||||
t.Errorf("auth header = %q, want the per-host bearer", gotAuth)
|
||||
}
|
||||
if resp.Generation != 4 {
|
||||
t.Errorf("generation = %d, want 4", resp.Generation)
|
||||
}
|
||||
if len(resp.DesiredState.Guests) != 2 {
|
||||
t.Fatalf("guests = %d, want 2", len(resp.DesiredState.Guests))
|
||||
}
|
||||
if resp.DesiredState.Guests[0].Run != "running" || !resp.DesiredState.Guests[1].Decommission {
|
||||
t.Errorf("guests = %+v", resp.DesiredState.Guests)
|
||||
}
|
||||
// Forward-compat restore_directive is carried through (consumed in 10D).
|
||||
if resp.DesiredState.RestoreDirective == nil || resp.DesiredState.RestoreDirective.Mode != "guest_loss" {
|
||||
t.Errorf("restore_directive = %+v, want carried (mode guest_loss)", resp.DesiredState.RestoreDirective)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-2xx response is a typed HTTPError (e.g. a 403 self-scope refusal from the hub).
|
||||
func TestFetchDesiredState_HTTPError(t *testing.T) {
|
||||
c := testClient(func(r *http.Request) (*http.Response, error) {
|
||||
return httpResp(403, `Forbidden: host_id mismatch`), nil
|
||||
})
|
||||
_, err := c.FetchDesiredState(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected an error on 403")
|
||||
}
|
||||
var he *HTTPError
|
||||
if !asHTTPError(err, &he) || he.StatusCode != 403 {
|
||||
t.Errorf("err = %v, want HTTPError 403", err)
|
||||
}
|
||||
}
|
||||
|
||||
func asHTTPError(err error, target **HTTPError) bool {
|
||||
for err != nil {
|
||||
if he, ok := err.(*HTTPError); ok {
|
||||
*target = he
|
||||
return true
|
||||
}
|
||||
type unwrapper interface{ Unwrap() error }
|
||||
if u, ok := err.(unwrapper); ok {
|
||||
err = u.Unwrap()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The desired-state wire is a contract DUPLICATED across two repos (no shared types module yet).
|
||||
// testdata/desired-state.golden.json and testdata/control-envelope.golden.json MUST be kept
|
||||
// byte-identical with felhom.eu/hub's copies; these tests decode them through the agent structs
|
||||
// and key-set-compare, catching drift between the struct and the served shape.
|
||||
func TestDesiredStateGolden_DecodesAndKeySet(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/desired-state.golden.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resp DesiredStateResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
t.Fatalf("golden does not decode into DesiredStateResponse: %v", err)
|
||||
}
|
||||
if resp.Generation != 4 {
|
||||
t.Errorf("generation = %d, want 4", resp.Generation)
|
||||
}
|
||||
if len(resp.DesiredState.Guests) != 2 {
|
||||
t.Fatalf("guests = %d, want 2", len(resp.DesiredState.Guests))
|
||||
}
|
||||
benign := resp.DesiredState.Guests[0]
|
||||
if benign.VMID != 100 || benign.Run != "running" || benign.Spec == nil || benign.Spec.Cores != 2 || benign.Description == nil {
|
||||
t.Errorf("benign guest = %+v", benign)
|
||||
}
|
||||
destructive := resp.DesiredState.Guests[1]
|
||||
if destructive.VMID != 200 || !destructive.Decommission {
|
||||
t.Errorf("destructive guest = %+v, want vmid 200 decommission", destructive)
|
||||
}
|
||||
if resp.DesiredState.PBSNamespace != "felhom-cust-acme" {
|
||||
t.Errorf("pbs_namespace = %q", resp.DesiredState.PBSNamespace)
|
||||
}
|
||||
if resp.DesiredState.RestoreDirective == nil || resp.DesiredState.RestoreDirective.Mode != "guest_loss" {
|
||||
t.Errorf("restore_directive = %+v, want carried (guest_loss)", resp.DesiredState.RestoreDirective)
|
||||
}
|
||||
|
||||
// Bidirectional key-set drift guard: the marshaled struct's keys must match the golden's
|
||||
// (top-level, the desired_state object, and a guest element).
|
||||
var golden map[string]any
|
||||
json.Unmarshal(raw, &golden)
|
||||
b, _ := json.Marshal(resp)
|
||||
var got map[string]any
|
||||
json.Unmarshal(b, &got)
|
||||
assertSameKeys(t, "<desired-state top>", golden, got)
|
||||
assertSameKeys(t, "desired_state", golden["desired_state"], got["desired_state"])
|
||||
assertSameKeys(t, "desired_state.guests[0]",
|
||||
firstElem(golden["desired_state"].(map[string]any)["guests"]),
|
||||
firstElem(got["desired_state"].(map[string]any)["guests"]))
|
||||
assertSameKeys(t, "desired_state.restore_directive",
|
||||
golden["desired_state"].(map[string]any)["restore_directive"],
|
||||
got["desired_state"].(map[string]any)["restore_directive"])
|
||||
}
|
||||
|
||||
func TestControlEnvelopeGolden_Decodes(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/control-envelope.golden.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var env ControlEnvelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("golden does not decode into ControlEnvelope: %v", err)
|
||||
}
|
||||
if env.Status != "ok" || env.PollIntervalSeconds == nil || *env.PollIntervalSeconds != 900 {
|
||||
t.Errorf("envelope poll/status = %+v", env)
|
||||
}
|
||||
if env.DesiredGeneration != 4 || !env.HasSignedOps || env.Blocked {
|
||||
t.Errorf("envelope flags = gen %d signed %v blocked %v", env.DesiredGeneration, env.HasSignedOps, env.Blocked)
|
||||
}
|
||||
}
|
||||
+23
-2
@@ -20,6 +20,15 @@ type collectorIface interface {
|
||||
Collect(ctx context.Context) (*HostReport, error)
|
||||
}
|
||||
|
||||
// EnvelopeObserver is notified of the hub's control envelope on every heartbeat (slice 10A).
|
||||
// The desired-state sync layer (internal/desired) implements it: when DesiredGeneration advances
|
||||
// past its cache it fetches the full desired-state and updates the engine's provider. Defined
|
||||
// here (consumer-side) so hub does NOT import the desired/reconcile packages — same seam pattern
|
||||
// as the collector's StorageObserver. A nil observer (no desired-state wiring) is a clean no-op.
|
||||
type EnvelopeObserver interface {
|
||||
OnEnvelope(ctx context.Context, env *ControlEnvelope)
|
||||
}
|
||||
|
||||
// Loop is the agent's first daemon run loop: collect a host-report, POST it, adopt
|
||||
// the hub's cadence, repeat. It is resilient — a collect or report error is logged
|
||||
// and the loop continues (the data plane is independent of the agent; a hub outage
|
||||
@@ -30,7 +39,8 @@ type Loop struct {
|
||||
client reporter
|
||||
interval time.Duration
|
||||
logger *slog.Logger
|
||||
trigger <-chan struct{} // optional: an out-of-band report request (storage watchdog)
|
||||
trigger <-chan struct{} // optional: an out-of-band report request (storage watchdog)
|
||||
observer EnvelopeObserver // optional: the slice-10A desired-state sync hook
|
||||
}
|
||||
|
||||
// NewLoop builds the loop. interval is the starting cadence (the hub may override it
|
||||
@@ -48,6 +58,11 @@ func NewLoop(collector collectorIface, client reporter, interval time.Duration,
|
||||
// so this fires at most once per debounce window.
|
||||
func (l *Loop) SetTrigger(ch <-chan struct{}) { l.trigger = ch }
|
||||
|
||||
// SetEnvelopeObserver wires the slice-10A desired-state sync hook. It is called once per cycle
|
||||
// with the hub's control envelope (after the interval is adopted), so the sync layer can fetch
|
||||
// desired-state when the generation advances. Optional — unset is a clean no-op.
|
||||
func (l *Loop) SetEnvelopeObserver(o EnvelopeObserver) { l.observer = o }
|
||||
|
||||
// Run reports immediately, then on each tick, until ctx is cancelled (then nil).
|
||||
func (l *Loop) Run(ctx context.Context) error {
|
||||
interval := l.interval
|
||||
@@ -97,9 +112,15 @@ func (l *Loop) cycle(ctx context.Context, current time.Duration) time.Duration {
|
||||
}
|
||||
l.logger.Debug("hub: report sent",
|
||||
"guests", len(report.Guests),
|
||||
// reserved/forward-compat envelope fields — logged only, never acted on (slice 4).
|
||||
"blocked", env.Blocked, "desired_generation", env.DesiredGeneration, "has_signed_ops", env.HasSignedOps)
|
||||
|
||||
// Slice 10A: hand the envelope to the desired-state sync hook (fetch desired-state on a
|
||||
// generation advance). Done off the report's critical path semantics — a sync/fetch failure
|
||||
// is the observer's concern and never affects the heartbeat cadence below.
|
||||
if l.observer != nil {
|
||||
l.observer.OnEnvelope(ctx, env)
|
||||
}
|
||||
|
||||
if env.PollIntervalSeconds == nil {
|
||||
return current
|
||||
}
|
||||
|
||||
@@ -35,6 +35,47 @@ func (r *fakeReporter) Report(ctx context.Context, _ *HostReport) (*ControlEnvel
|
||||
return r.env, nil
|
||||
}
|
||||
|
||||
// recordingObserver records the envelopes the loop hands it (slice 10A EnvelopeObserver seam).
|
||||
type recordingObserver struct{ envs []*ControlEnvelope }
|
||||
|
||||
func (o *recordingObserver) OnEnvelope(_ context.Context, e *ControlEnvelope) { o.envs = append(o.envs, e) }
|
||||
|
||||
// The loop notifies the EnvelopeObserver once per successful cycle (with the envelope) AND still
|
||||
// adopts PollIntervalSeconds — the two are independent.
|
||||
func TestLoop_CycleNotifiesObserverAndAdoptsInterval(t *testing.T) {
|
||||
var cn, rn int32
|
||||
env := &ControlEnvelope{DesiredGeneration: 3, HasSignedOps: true, PollIntervalSeconds: intPtr(120)}
|
||||
loop := NewLoop(
|
||||
&fakeCollector{report: &HostReport{}, n: &cn},
|
||||
&fakeReporter{env: env, n: &rn},
|
||||
900*time.Second, quietLogger())
|
||||
obs := &recordingObserver{}
|
||||
loop.SetEnvelopeObserver(obs)
|
||||
|
||||
got := loop.cycle(context.Background(), 900*time.Second)
|
||||
if len(obs.envs) != 1 || obs.envs[0].DesiredGeneration != 3 || !obs.envs[0].HasSignedOps {
|
||||
t.Fatalf("observer envelopes = %+v, want 1 with gen 3 + has_signed_ops", obs.envs)
|
||||
}
|
||||
if got != 120*time.Second {
|
||||
t.Errorf("poll interval = %v, want 120s adopted alongside the observer notify", got)
|
||||
}
|
||||
}
|
||||
|
||||
// On a report failure the observer is NOT notified (there is no envelope to act on).
|
||||
func TestLoop_ReportErrorSkipsObserver(t *testing.T) {
|
||||
var cn, rn int32
|
||||
loop := NewLoop(
|
||||
&fakeCollector{report: &HostReport{}, n: &cn},
|
||||
&fakeReporter{env: &ControlEnvelope{}, errSeq: []error{errors.New("hub 5xx")}, n: &rn},
|
||||
900*time.Second, quietLogger())
|
||||
obs := &recordingObserver{}
|
||||
loop.SetEnvelopeObserver(obs)
|
||||
loop.cycle(context.Background(), 900*time.Second)
|
||||
if len(obs.envs) != 0 {
|
||||
t.Errorf("observer notified on a report error: %+v", obs.envs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampInterval(t *testing.T) {
|
||||
cases := []struct {
|
||||
in int
|
||||
|
||||
@@ -19,7 +19,7 @@ func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { retu
|
||||
|
||||
// testClient builds a hub Client over a mock transport (no network).
|
||||
func testClient(rt roundTripFunc) *Client {
|
||||
return newClient("https://hub.example.test", "super-secret-bearer-key", &http.Client{Transport: rt}, quietLogger())
|
||||
return newClient("https://hub.example.test", "super-secret-bearer-key", "demo-host-01", &http.Client{Transport: rt}, quietLogger())
|
||||
}
|
||||
|
||||
func httpResp(code int, body string) *http.Response {
|
||||
|
||||
+55
-6
@@ -1,5 +1,7 @@
|
||||
package hub
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// HostReport is the wire contract shared with the hub's ingest
|
||||
// (felhom.eu TASK-slice3-hub-ingest). Field NAMES must match the hub
|
||||
// field-for-field. Encoding is ordinary encoding/json (no canonicalization —
|
||||
@@ -233,15 +235,62 @@ type PBSSnapshot struct {
|
||||
|
||||
type AuditEntry struct{} // audit-log tail entry fields TBD
|
||||
|
||||
// ControlEnvelope is the hub's 200 response to a host-report. This slice the agent
|
||||
// adopts ONLY PollIntervalSeconds; the rest are reserved/forward-compat fields it
|
||||
// logs at most and never acts on (reconcile, slice 4, consumes them).
|
||||
// ControlEnvelope is the hub's 200 response to a host-report — the "Down" channel (slice 10A).
|
||||
// It is a cheap change-notification on every heartbeat: the agent adopts PollIntervalSeconds,
|
||||
// and when DesiredGeneration ADVANCES past its cached one it fetches the full desired-state from
|
||||
// GET /hosts/{id}/desired-state (the heavy state moves only on change). HasSignedOps flags a
|
||||
// non-empty signed-jobs queue (the agent fetches/executes them in 10B). Blocked stays reserved.
|
||||
type ControlEnvelope struct {
|
||||
Status string `json:"status"`
|
||||
// PollIntervalSeconds is a pointer so a missing field (keep current interval) is
|
||||
// distinguishable from an explicit 0.
|
||||
PollIntervalSeconds *int `json:"poll_interval_seconds"`
|
||||
Blocked bool `json:"blocked"` // reserved — ignored (slice 4)
|
||||
DesiredGeneration int64 `json:"desired_generation"` // reserved — ignored (slice 4)
|
||||
HasSignedOps bool `json:"has_signed_ops"` // reserved — ignored (slice 4)
|
||||
Blocked bool `json:"blocked"` // reserved — ignored
|
||||
DesiredGeneration int64 `json:"desired_generation"` // slice 10A: the cached-vs-current change signal
|
||||
HasSignedOps bool `json:"has_signed_ops"` // slice 10A: signed-jobs queue non-empty (exec 10B)
|
||||
}
|
||||
|
||||
// DesiredStateResponse is GET /hosts/{host_id}/desired-state (slice 10A — the "Down" channel's
|
||||
// heavy payload, fetched only when the envelope's generation advances). Generation is the
|
||||
// generation this state corresponds to, so the agent caches state+generation atomically. This is
|
||||
// a cross-repo wire contract (DUPLICATED in felhom.eu/hub until a shared module exists); the
|
||||
// desired-state golden stays byte-identical across the two repos.
|
||||
type DesiredStateResponse struct {
|
||||
Generation int64 `json:"generation"`
|
||||
DesiredState WireDesiredState `json:"desired_state"`
|
||||
}
|
||||
|
||||
// WireDesiredState is the hub's authoritative per-host target (slice 10A). The agent reconciles the
|
||||
// parts it can today (guests: benign deltas reconciled, an explicit decommission gated
|
||||
// pending_signature); the rest are FORWARD-COMPAT — carried + cached, NOT acted on in 10A. The
|
||||
// restore_directive is consumed in 10D (host/guest-loss DR); storage_manifest / backup_policy /
|
||||
// pbs_namespace are placeholders kept opaque so the wire is stable as those land.
|
||||
type WireDesiredState struct {
|
||||
Guests []WireDesiredGuest `json:"guests"`
|
||||
|
||||
StorageManifest json.RawMessage `json:"storage_manifest,omitempty"`
|
||||
BackupPolicy json.RawMessage `json:"backup_policy,omitempty"`
|
||||
PBSNamespace string `json:"pbs_namespace,omitempty"`
|
||||
RestoreDirective *WireRestoreDirective `json:"restore_directive,omitempty"` // slice 10D (forward-compat)
|
||||
}
|
||||
|
||||
// WireDesiredGuest is one guest's target (slice 10A). Every field is optional ("unmanaged"); the
|
||||
// agent's planner acts only on the fields that are set. Run is running|stopped|""; Spec reuses
|
||||
// GuestSpec (cores/memory_bytes/disk_bytes); Decommission is the EXPLICIT destructive delta (gated
|
||||
// pending_signature in 10A — executor is 10B).
|
||||
type WireDesiredGuest struct {
|
||||
VMID int `json:"vmid"`
|
||||
Run string `json:"run,omitempty"`
|
||||
Spec *GuestSpec `json:"spec,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Decommission bool `json:"decommission,omitempty"`
|
||||
}
|
||||
|
||||
// WireRestoreDirective is the forward-compat restore directive (slice 10D — host/guest-loss DR).
|
||||
// Defined now so the wire contract is stable; 10A carries it through to the cache but does NOT
|
||||
// consume it (no restore is initiated from desired-state in 10A).
|
||||
type WireRestoreDirective struct {
|
||||
Mode string `json:"mode,omitempty"` // guest_loss | host_loss (10D vocabulary)
|
||||
Archive string `json:"archive,omitempty"` // source archive/snapshot to restore from
|
||||
VMID int `json:"vmid,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"poll_interval_seconds": 900,
|
||||
"blocked": false,
|
||||
"desired_generation": 4,
|
||||
"has_signed_ops": true
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"generation": 4,
|
||||
"desired_state": {
|
||||
"guests": [
|
||||
{
|
||||
"vmid": 100,
|
||||
"run": "running",
|
||||
"spec": { "cores": 2, "memory_bytes": 2147483648, "disk_bytes": 21474836480 },
|
||||
"description": "felhom: acme prod"
|
||||
},
|
||||
{
|
||||
"vmid": 200,
|
||||
"decommission": true
|
||||
}
|
||||
],
|
||||
"pbs_namespace": "felhom-cust-acme",
|
||||
"restore_directive": {
|
||||
"mode": "guest_loss",
|
||||
"archive": "local:backup/vzdump-lxc-200-2026_06_09-11_00_00.tar.zst",
|
||||
"vmid": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user