Support PATCH updates for telemetry status

Send only changing fields for status updates and add server-side update flow.

- Trimmed telemetry JSON payload in misc/api.func to include only status, error, and exit_code (removed static fields and pve_version) so updates are minimal.
- Added TelemetryStatusUpdate type and new PBClient methods: FindRecordByRandomID, UpdateTelemetryStatus, and UpsertTelemetry in misc/data/service.go. UpsertTelemetry creates a record for status="installing", otherwise finds the record by random_id and PATCHes only status/error/exit_code (fallbacks to create if not found).
- Relaxed validation logic in validate(): detect updates (status != "installing") and skip certain strict numeric checks for update requests while keeping required fields and other validations.
- Main handler now calls UpsertTelemetry instead of CreateTelemetry and logs generic errors.

These changes allow idempotent, minimal updates to existing telemetry records and avoid repeatedly sending/storing unchanged metadata.
This commit is contained in:
CanbiZ (MickLesk) 2026-02-09 17:07:30 +01:00
parent 878672a8df
commit 88a540e457
2 changed files with 124 additions and 19 deletions

View File

@ -378,12 +378,8 @@ post_update_to_api() {
[[ -z "$error" ]] && error="Unknown error"
fi
# Get PVE version for complete record
local pve_version=""
if command -v pveversion &>/dev/null; then
pve_version=$(pveversion 2>/dev/null | awk -F'[/ ]' '{print $2}') || true
fi
# Update payload: only fields that change (status, error, exit_code)
# The Go service will find the record by random_id and PATCH only these fields
local JSON_PAYLOAD
JSON_PAYLOAD=$(
cat <<EOF
@ -392,14 +388,6 @@ post_update_to_api() {
"type": "${TELEMETRY_TYPE:-lxc}",
"nsapp": "${NSAPP:-unknown}",
"status": "${pb_status}",
"ct_type": ${CT_TYPE:-1},
"disk_size": ${DISK_SIZE:-0},
"core_count": ${CORE_COUNT:-0},
"ram_size": ${RAM_SIZE:-0},
"os_type": "${var_os:-}",
"os_version": "${var_version:-}",
"pve_version": "${pve_version}",
"method": "${METHOD:-default}",
"exit_code": ${exit_code},
"error": "${error}"
}

View File

@ -82,6 +82,13 @@ type TelemetryOut struct {
ExitCode int `json:"exit_code,omitempty"`
}
// TelemetryStatusUpdate contains only fields needed for status updates
type TelemetryStatusUpdate struct {
Status string `json:"status"`
Error string `json:"error,omitempty"`
ExitCode int `json:"exit_code"`
}
type PBClient struct {
baseURL string
authCollection string
@ -159,6 +166,109 @@ func (p *PBClient) ensureAuth(ctx context.Context) error {
return nil
}
// FindRecordByRandomID searches for an existing record by random_id
func (p *PBClient) FindRecordByRandomID(ctx context.Context, randomID string) (string, error) {
if err := p.ensureAuth(ctx); err != nil {
return "", err
}
// URL encode the filter
filter := fmt.Sprintf("random_id='%s'", randomID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("%s/api/collections/%s/records?filter=%s&fields=id&perPage=1",
p.baseURL, p.targetColl, filter),
nil,
)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+p.token)
resp, err := p.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("pocketbase search failed: %s", resp.Status)
}
var result struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if len(result.Items) == 0 {
return "", nil // Not found
}
return result.Items[0].ID, nil
}
// UpdateTelemetryStatus updates only status, error, and exit_code of an existing record
func (p *PBClient) UpdateTelemetryStatus(ctx context.Context, recordID string, update TelemetryStatusUpdate) error {
if err := p.ensureAuth(ctx); err != nil {
return err
}
b, _ := json.Marshal(update)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch,
fmt.Sprintf("%s/api/collections/%s/records/%s", p.baseURL, p.targetColl, recordID),
bytes.NewReader(b),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.token)
resp, err := p.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
return fmt.Errorf("pocketbase update failed: %s: %s", resp.Status, strings.TrimSpace(string(rb)))
}
return nil
}
// UpsertTelemetry handles both creation and updates intelligently
// - status="installing": Always creates a new record
// - status!="installing": Updates existing record (found by random_id) with status/error/exit_code only
func (p *PBClient) UpsertTelemetry(ctx context.Context, payload TelemetryOut) error {
// For "installing" status, always create new record
if payload.Status == "installing" {
return p.CreateTelemetry(ctx, payload)
}
// For status updates (sucess/failed/unknown), find and update existing record
recordID, err := p.FindRecordByRandomID(ctx, payload.RandomID)
if err != nil {
// Search failed, log and return error
return fmt.Errorf("cannot find record to update: %w", err)
}
if recordID == "" {
// Record not found - this shouldn't happen normally
// Create a full record as fallback
return p.CreateTelemetry(ctx, payload)
}
// Update only status, error, and exit_code
update := TelemetryStatusUpdate{
Status: payload.Status,
Error: payload.Error,
ExitCode: payload.ExitCode,
}
return p.UpdateTelemetryStatus(ctx, recordID, update)
}
func (p *PBClient) CreateTelemetry(ctx context.Context, payload TelemetryOut) error {
if err := p.ensureAuth(ctx); err != nil {
return err
@ -350,7 +460,7 @@ func validate(in *TelemetryIn) error {
// IMPORTANT: "error" must be short and not contain identifiers/logs
in.Error = sanitizeShort(in.Error, 120)
// Required fields
// Required fields for all requests
if in.RandomID == "" || in.Type == "" || in.NSAPP == "" || in.Status == "" {
return errors.New("missing required fields: random_id, type, nsapp, status")
}
@ -363,6 +473,10 @@ func validate(in *TelemetryIn) error {
return errors.New("invalid status")
}
// For status updates (not installing), skip numeric field validation
// These are only required for initial creation
isUpdate := in.Status != "installing"
// os_type is optional but if provided must be valid
if in.OsType != "" && !allowedOsType[in.OsType] {
return errors.New("invalid os_type")
@ -371,9 +485,11 @@ func validate(in *TelemetryIn) error {
// method is optional and flexible - just sanitized, no strict validation
// Values like "default", "advanced", "mydefaults-global", "mydefaults-app" are all valid
// Validate numeric ranges
if in.CTType < 0 || in.CTType > 2 {
return errors.New("invalid ct_type (must be 0, 1, or 2)")
// Validate numeric ranges (only strict for new records)
if !isUpdate {
if in.CTType < 0 || in.CTType > 2 {
return errors.New("invalid ct_type (must be 0, 1, or 2)")
}
}
if in.DiskSize < 0 || in.DiskSize > 100000 {
return errors.New("invalid disk_size")
@ -511,7 +627,8 @@ func main() {
ctx, cancel := context.WithTimeout(r.Context(), cfg.RequestTimeout)
defer cancel()
if err := pb.CreateTelemetry(ctx, out); err != nil {
// Upsert: Creates new record if random_id doesn't exist, updates if it does
if err := pb.UpsertTelemetry(ctx, out); err != nil {
// GDPR: don't log raw payload, don't log IPs; log only generic error
log.Printf("pocketbase write failed: %v", err)
http.Error(w, "upstream error", http.StatusBadGateway)