mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-29 19:03:59 +08:00
Add branch and historical version selection to Galaxy
This commit is contained in:
committed by
firestar5683
parent
6b8bb279d4
commit
c4f46c51b2
@@ -1899,3 +1899,24 @@ button.gx-chip:hover {
|
||||
.gx-monitor td:first-child { min-width:180px; max-width:350px; white-space:normal; overflow-wrap:anywhere; }
|
||||
.gx-monitor tbody tr:hover { background:var(--surface); }
|
||||
@media(max-width:600px) { .gx-monitor__summary { grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .gx-monitor__metric { padding:12px; } .gx-monitor__metric strong { font-size:22px; } }
|
||||
|
||||
/* Version and branch selector menus. */
|
||||
.gx-select { display: inline-flex; position: relative; vertical-align: middle; min-width: 0; max-width: 100%; }
|
||||
.gx-select.gx-field { padding: 0; background: var(--surface-container-high); }
|
||||
.gx-select.gx-field--full { display: flex; width: 100%; }
|
||||
.gx-select__button { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-width: 0; min-height: 44px; padding: 10px var(--sp-3); border: 0; border-radius: inherit; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; }
|
||||
.gx-select__button > span { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
|
||||
.gx-select__button > i { flex-shrink: 0; }
|
||||
.gx-select:focus-within { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.gx-select__button:disabled { opacity: .5; cursor: default; }
|
||||
.gx-select-menu { position: fixed; inset: auto; margin: 0; padding: 6px; box-sizing: border-box; max-width: none; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--outline-variant, var(--glass-border)); border-radius: var(--radius-md); color: var(--on-surface); background: var(--surface-container-high, #252033); box-shadow: 0 12px 36px #0006; font: var(--fs-base) var(--font-body); }
|
||||
.gx-select-menu::backdrop { background: transparent; }
|
||||
.gx-select-menu [role="option"] { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-height: 44px; padding: 10px 12px; border: 0; border-radius: 10px; background: transparent; color: inherit; text-align: left; font: inherit; white-space: normal; overflow-wrap: anywhere; cursor: pointer; }
|
||||
.gx-select-menu [role="option"][aria-selected="true"] { background: var(--primary-container); color: var(--on-primary-container); }
|
||||
.gx-select-menu [role="option"]:hover:not(:disabled), .gx-select-menu [role="option"]:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; background: var(--primary-container); color: var(--on-primary-container); }
|
||||
.gx-select-menu [role="option"]:disabled { opacity: .45; cursor: default; }
|
||||
.gx-select-menu__group { padding: 10px 12px 4px; color: var(--on-surface-variant); font-size: .85em; }
|
||||
|
||||
.gx-select-menu { background: linear-gradient(var(--surface-container-high), var(--surface-container-high)), #141020; }
|
||||
[data-theme="light"] .gx-select-menu { background: linear-gradient(var(--surface-container-high), var(--surface-container-high)), #fff; }
|
||||
.gx-select-menu:not([open]) { display: none; }
|
||||
|
||||
@@ -228,6 +228,12 @@ export const api = {
|
||||
getUpdateBranches() { return request("/api/update/branches") },
|
||||
getUpdateBranch() { return request("/api/update/branch") },
|
||||
setUpdateBranch(branch) { return request("/api/update/branch", { method: "POST", data: { branch } }) },
|
||||
getUpdateVersions(branch, { page = 1, head = "", signal } = {}) {
|
||||
const query = new URLSearchParams({ branch, page: String(page) })
|
||||
if (head) query.set("head", head)
|
||||
return request(`/api/update/versions?${query}`, { cache: "no-store", signal })
|
||||
},
|
||||
installUpdateVersion(branch, commit) { return request("/api/update/version", { method: "POST", data: { branch, commit, confirmed: true } }) },
|
||||
updateFast() { return request("/api/update/fast", { method: "POST" }) },
|
||||
getUpdateFastStatus() { return request("/api/update/fast/status") },
|
||||
updateRecover() { return request("/api/update/recover", { method: "POST" }) },
|
||||
|
||||
@@ -34,7 +34,7 @@ export const GalaxyModal = {
|
||||
<transition name="gx-slide" appear>
|
||||
<div class="gx-sheet" role="dialog" :aria-label="title">
|
||||
<h3 class="gx-sheet__title">{{ title }}</h3>
|
||||
<p v-if="message" style="color: var(--text-muted); line-height: 1.5;">{{ message }}</p>
|
||||
<p v-if="message" style="color: var(--text-muted); line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere;">{{ message }}</p>
|
||||
<input v-if="input" ref="input" v-model="value" class="gx-field gx-field--full" type="text"
|
||||
:placeholder="inputPlaceholder" @keyup.enter="confirm" />
|
||||
<div class="gx-dialog__actions">
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
let nextId = 0
|
||||
|
||||
// Keep the native select as the value/event adapter; presentation belongs to Galaxy.
|
||||
export const GalaxySelect = {
|
||||
name: "GalaxySelect",
|
||||
inheritAttrs: false,
|
||||
props: { value: { default: undefined }, modelValue: { default: undefined }, disabled: Boolean },
|
||||
emits: ["change", "update:modelValue"],
|
||||
data() { return { uid: `gx-select-${++nextId}`, items: [], selected: "", label: "", open: false, name: "Choose an option", typeahead: "", typedAt: 0 } },
|
||||
computed: {
|
||||
current() { return this.modelValue !== undefined ? this.modelValue : this.value },
|
||||
buttonId() { return this.$attrs.id || this.uid },
|
||||
nativeAttrs() {
|
||||
const attrs = { ...this.$attrs }
|
||||
for (const key of ["class", "style", "id", "aria-label", "aria-labelledby", "aria-describedby"]) delete attrs[key]
|
||||
return attrs
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
sync() {
|
||||
const native = this.$refs.native
|
||||
if (!native) return
|
||||
if (this.current !== undefined) native.value = String(this.current ?? "")
|
||||
const items = [...native.options].map((option, index) => ({ index, value: option.value, label: option.label, description: option.dataset?.description || "", disabled: option.disabled || (option.parentElement?.tagName === "OPTGROUP" && option.parentElement.disabled), group: option.parentElement?.tagName === "OPTGROUP" ? option.parentElement.label : "" }))
|
||||
if (JSON.stringify(items) !== JSON.stringify(this.items)) this.items = items
|
||||
this.selected = native.value
|
||||
this.label = native.selectedOptions[0]?.dataset?.collapsedLabel || native.selectedOptions[0]?.label || "Choose an option"
|
||||
const button = this.$refs.button
|
||||
const labelled = this.$attrs["aria-labelledby"]?.split(/\s+/).map(id => document.getElementById(id)?.textContent || "").join(" ")
|
||||
const labels = [...new Set([...(button?.labels || []), button?.closest("label")].filter(Boolean))].map(label => {
|
||||
const copy = label.cloneNode(true)
|
||||
copy.querySelectorAll(".gx-select, select, button").forEach(node => node.remove())
|
||||
return copy.textContent.trim()
|
||||
}).filter(Boolean).join(" ")
|
||||
this.name = this.$attrs["aria-label"] || labelled || labels || button?.closest(".gx-row")?.querySelector(".gx-row__label")?.textContent || this.$attrs.title || "Choose an option"
|
||||
if (this.disabled && this.open) this.close()
|
||||
},
|
||||
async show(event) {
|
||||
if (this.disabled) return
|
||||
this.sync()
|
||||
this.open = true
|
||||
await this.$nextTick()
|
||||
const menu = this.$refs.menu
|
||||
menu.showModal()
|
||||
this.position()
|
||||
const options = [...menu.querySelectorAll('[role="option"]:not(:disabled)')]
|
||||
const selected = options.find(option => option.dataset.value === this.selected)
|
||||
const first = event?.key === "End" ? options.at(-1) : options[0]
|
||||
;(selected || first)?.focus()
|
||||
},
|
||||
close() {
|
||||
if (!this.open) return
|
||||
this.open = false
|
||||
this.$refs.menu?.close()
|
||||
if (this.$refs.button?.isConnected) this.$refs.button.focus({ preventScroll: true })
|
||||
},
|
||||
position() {
|
||||
if (!this.open) return
|
||||
const rect = this.$refs.button.getBoundingClientRect()
|
||||
const viewport = window.visualViewport
|
||||
const width = viewport?.width || window.innerWidth
|
||||
const height = viewport?.height || window.innerHeight
|
||||
const menu = this.$refs.menu
|
||||
let zoom = 1
|
||||
for (let node = menu; node; node = node.parentElement) zoom *= Number.parseFloat(getComputedStyle(node).zoom) || 1
|
||||
const menuWidth = Math.min(Math.max(rect.width, 240), width - 24)
|
||||
menu.style.width = `${menuWidth / zoom}px`
|
||||
menu.style.maxHeight = `${(height - 24) / zoom}px`
|
||||
const menuHeight = Math.min(menu.scrollHeight * zoom + 2, height - 24)
|
||||
const below = height - rect.bottom - 12
|
||||
const top = below >= Math.min(menuHeight, 220) ? Math.min(rect.bottom + 6, height - menuHeight - 12) : Math.max(12, rect.top - menuHeight - 6)
|
||||
menu.style.left = `${Math.max(12, Math.min(rect.left, width - menuWidth - 12)) / zoom}px`
|
||||
menu.style.top = `${Math.max(12, top) / zoom}px`
|
||||
},
|
||||
select(item) {
|
||||
if (this.disabled) return this.close()
|
||||
const native = this.$refs.native
|
||||
const option = native.options[item.index]
|
||||
if (!option || option.value !== item.value || option.disabled || option.parentElement?.disabled) return this.close()
|
||||
this.close()
|
||||
if (native.value === option.value) return
|
||||
native.value = option.value
|
||||
native.dispatchEvent(new Event("change", { bubbles: true }))
|
||||
this.sync()
|
||||
},
|
||||
change(event) {
|
||||
this.$emit("update:modelValue", event.target.value)
|
||||
this.$emit("change", event)
|
||||
},
|
||||
buttonKey(event) {
|
||||
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { event.preventDefault(); this.show(event) }
|
||||
},
|
||||
menuKey(event) {
|
||||
if (event.key === "Tab") { event.preventDefault(); this.close(); return }
|
||||
const options = [...this.$refs.menu.querySelectorAll('[role="option"]:not(:disabled)')]
|
||||
const index = options.indexOf(document.activeElement)
|
||||
let target
|
||||
if (event.key === "ArrowDown") target = options[(index + 1) % options.length]
|
||||
if (event.key === "ArrowUp") target = options[(index - 1 + options.length) % options.length]
|
||||
if (event.key === "Home") target = options[0]
|
||||
if (event.key === "End") target = options.at(-1)
|
||||
if (event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
const now = performance.now()
|
||||
this.typeahead = (now - this.typedAt < 700 ? this.typeahead : "") + event.key.toLocaleLowerCase()
|
||||
this.typedAt = now
|
||||
target = options.find(option => option.textContent.trim().toLocaleLowerCase().startsWith(this.typeahead))
|
||||
}
|
||||
if (target) { event.preventDefault(); target.focus() }
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.sync()
|
||||
window.addEventListener("resize", this.position)
|
||||
window.visualViewport?.addEventListener("resize", this.position)
|
||||
},
|
||||
updated() { this.sync() },
|
||||
beforeUnmount() {
|
||||
this.$refs.menu?.close()
|
||||
window.removeEventListener("resize", this.position)
|
||||
window.visualViewport?.removeEventListener("resize", this.position)
|
||||
},
|
||||
template: `
|
||||
<span class="gx-select" :class="$attrs.class" :style="$attrs.style">
|
||||
<select ref="native" v-bind="nativeAttrs" hidden tabindex="-1" aria-hidden="true" :disabled="disabled" @change="change"><slot /></select>
|
||||
<button ref="button" :id="buttonId" class="gx-select__button" type="button" role="combobox" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="uid + '-list'"
|
||||
:aria-label="name" :aria-describedby="$attrs['aria-describedby']" :disabled="disabled" @click="show" @keydown="buttonKey">
|
||||
<span>{{ label }}</span><i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<dialog ref="menu" class="gx-select-menu" @cancel.prevent="close" @click="event => { if (event.target === $refs.menu) close() }" @keydown="menuKey">
|
||||
<div :id="uid + '-list'" role="listbox" :aria-label="name">
|
||||
<template v-for="(item, index) in items" :key="item.index">
|
||||
<div v-if="item.group && item.group !== items[index - 1]?.group" class="gx-select-menu__group">{{ item.group }}</div>
|
||||
<button type="button" role="option" :data-value="item.value" :aria-selected="item.value === selected" :disabled="item.disabled" @click="select(item)">
|
||||
<span style="min-width:0; overflow-wrap:anywhere;"><span>{{ item.label }}</span><small v-if="item.description" class="gx-note" style="display:block; margin-top:4px; white-space:normal; line-height:1.4;">{{ item.description }}</small></span><i v-if="item.value === selected" class="bi bi-check-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</dialog>
|
||||
</span>
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
let nextHistoryId = 0
|
||||
|
||||
function validDate(value) {
|
||||
const date = value ? new Date(value) : null
|
||||
return date && Number.isFinite(date.getTime()) ? date : null
|
||||
}
|
||||
|
||||
function dateLabel(date) {
|
||||
return date.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })
|
||||
}
|
||||
|
||||
export function groupVersionHistory(commits) {
|
||||
const groups = new Map()
|
||||
for (const commit of commits) {
|
||||
const date = validDate(commit.date)
|
||||
// Local calendar days match the local date/time displayed for each build.
|
||||
const key = date ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}` : "unknown"
|
||||
if (!groups.has(key)) groups.set(key, { key, label: date ? dateLabel(date) : "Unknown date", commits: [] })
|
||||
groups.get(key).commits.push(commit)
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => a.key === "unknown" ? 1 : b.key === "unknown" ? -1 : b.key.localeCompare(a.key))
|
||||
}
|
||||
|
||||
// History arrives newest first. Keep that exact build when older pages repeat a release.
|
||||
export function releaseVersions(commits) {
|
||||
const versions = new Map()
|
||||
for (const commit of commits) {
|
||||
const key = commit.version || ""
|
||||
if (!versions.has(key)) versions.set(key, commit)
|
||||
}
|
||||
return [...versions.values()].sort((a, b) => {
|
||||
if (!a.version) return b.version ? 1 : 0
|
||||
if (!b.version) return -1
|
||||
const left = a.version.split(".").map(Number), right = b.version.split(".").map(Number)
|
||||
return right[0] - left[0] || right[1] - left[1] || right[2] - left[2]
|
||||
})
|
||||
}
|
||||
|
||||
export function versionTitle(commit, releaseBranch) {
|
||||
const date = validDate(commit?.date)
|
||||
const when = date ? `${dateLabel(date)} · ${date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}` : "Unknown date"
|
||||
return releaseBranch ? `${commit?.version || "Unnumbered version"} · ${when}` : `${commit?.subject || "Untitled change"} · ${when} · ${String(commit?.sha || "").slice(0, 10)}`
|
||||
}
|
||||
|
||||
export const VersionHistoryPicker = {
|
||||
name: "VersionHistoryPicker",
|
||||
inheritAttrs: false,
|
||||
props: { value: { default: "" }, commits: { type: Array, default: () => [] }, releaseBranch: Boolean, loading: Boolean, hasMore: Boolean, error: { default: "" }, notice: { default: "" }, disabled: Boolean },
|
||||
emits: ["change", "loadmore"],
|
||||
data() { return { uid: `gx-history-${++nextHistoryId}`, open: false, expanded: {} } },
|
||||
computed: {
|
||||
choices() { return this.releaseBranch ? releaseVersions(this.commits) : this.commits },
|
||||
groups() { return groupVersionHistory(this.choices) },
|
||||
selected() { return this.choices.find(commit => commit.sha === this.value) },
|
||||
label() { return this.selected ? versionTitle(this.selected, this.releaseBranch) : "Select an earlier version" },
|
||||
range() {
|
||||
if (this.releaseBranch) return `${this.choices.length} ${this.choices.length === 1 ? "release" : "releases"} · ${this.commits.length} builds checked`
|
||||
const known = this.groups.filter(group => group.key !== "unknown")
|
||||
if (!known.length) return this.commits.length ? `${this.commits.length} versions loaded` : "No history loaded yet"
|
||||
return `${this.commits.length} versions · ${known.at(-1).label}${known.length > 1 ? ` – ${known[0].label}` : ""}`
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
commits: { immediate: true, handler() { this.syncDays() } },
|
||||
disabled(value) { if (value) this.close() },
|
||||
},
|
||||
methods: {
|
||||
versionTitle,
|
||||
syncDays() {
|
||||
const firstLoad = !Object.keys(this.expanded).length
|
||||
const expanded = {}
|
||||
for (const [index, group] of this.groups.entries()) expanded[group.key] = this.expanded[group.key] ?? (firstLoad && index === 0)
|
||||
this.expanded = expanded
|
||||
},
|
||||
async show() {
|
||||
if (this.disabled) return
|
||||
const selectedDay = this.groups.find(group => group.commits.some(commit => commit.sha === this.value))
|
||||
if (selectedDay) this.expanded[selectedDay.key] = true
|
||||
this.open = true
|
||||
await this.$nextTick()
|
||||
this.$refs.menu.showModal()
|
||||
this.position()
|
||||
;(this.$refs.menu.querySelector('[aria-selected="true"]') || this.$refs.menu.querySelector('[data-day]') || this.$refs.close).focus()
|
||||
},
|
||||
close() {
|
||||
if (!this.open) return
|
||||
this.open = false
|
||||
this.$refs.menu?.close()
|
||||
if (this.$refs.button?.isConnected) this.$refs.button.focus({ preventScroll: true })
|
||||
},
|
||||
position() {
|
||||
if (!this.open) return
|
||||
const viewport = window.visualViewport
|
||||
const width = viewport?.width || window.innerWidth
|
||||
const height = viewport?.height || window.innerHeight
|
||||
const menu = this.$refs.menu
|
||||
const rect = this.$refs.button.getBoundingClientRect()
|
||||
let zoom = 1
|
||||
for (let node = menu; node; node = node.parentElement) zoom *= Number.parseFloat(getComputedStyle(node).zoom) || 1
|
||||
const menuWidth = Math.min(Math.max(rect.width, 420), width - 24)
|
||||
const menuHeight = Math.min(640, height - 24)
|
||||
menu.style.width = `${menuWidth / zoom}px`
|
||||
menu.style.height = `${menuHeight / zoom}px`
|
||||
menu.style.left = `${Math.max(12, Math.min(rect.left, width - menuWidth - 12)) / zoom}px`
|
||||
menu.style.top = `${Math.max(12, Math.min(rect.bottom + 6, height - menuHeight - 12)) / zoom}px`
|
||||
},
|
||||
select(sha) {
|
||||
if (this.disabled || !/^[a-f0-9]{40}$/.test(sha) || !(this.releaseBranch ? releaseVersions(this.commits) : this.commits).some(commit => commit.sha === sha)) return
|
||||
this.$emit("change", { target: { value: sha } })
|
||||
this.close()
|
||||
},
|
||||
loadMore() {
|
||||
if (this.disabled || this.loading || (!this.hasMore && !this.error)) return
|
||||
this.$emit("loadmore")
|
||||
},
|
||||
buttonKey(event) {
|
||||
if (["ArrowDown", "ArrowUp"].includes(event.key)) { event.preventDefault(); this.show() }
|
||||
},
|
||||
menuKey(event) {
|
||||
const target = event.target
|
||||
const day = target.closest("[data-day-group]")
|
||||
if (day && ["ArrowLeft", "ArrowRight"].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
this.expanded[day.dataset.dayGroup] = event.key === "ArrowRight"
|
||||
day.querySelector("[data-day]").focus()
|
||||
return
|
||||
}
|
||||
const items = [...this.$refs.menu.querySelectorAll("button:not(:disabled)")].filter(button => button.getClientRects().length)
|
||||
const index = items.indexOf(target)
|
||||
let next
|
||||
if (event.key === "ArrowDown") next = items[(index + 1) % items.length]
|
||||
if (event.key === "ArrowUp") next = items[(index - 1 + items.length) % items.length]
|
||||
if (event.key === "Home") next = items[0]
|
||||
if (event.key === "End") next = items.at(-1)
|
||||
if (next) { event.preventDefault(); next.focus() }
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener("resize", this.position)
|
||||
window.visualViewport?.addEventListener("resize", this.position)
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.$refs.menu?.close()
|
||||
window.removeEventListener("resize", this.position)
|
||||
window.visualViewport?.removeEventListener("resize", this.position)
|
||||
},
|
||||
template: `
|
||||
<div class="gx-history-picker" style="min-width:0;">
|
||||
<span class="gx-select gx-field gx-field--full">
|
||||
<button ref="button" :id="$attrs.id || uid" class="gx-select__button" type="button" aria-haspopup="dialog" :aria-expanded="open" :aria-controls="uid + '-dialog'"
|
||||
:aria-label="'Earlier version: ' + label" :disabled="disabled" @click="show" @keydown="buttonKey">
|
||||
<span style="white-space:normal; overflow-wrap:anywhere;">{{ label }}</span><i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
</span>
|
||||
<p class="gx-note" style="margin:6px 0 0;">{{ range }}. {{ hasMore ? (releaseBranch ? 'Open the picker to load older versions.' : 'Open the picker to load older days.') : (commits.length && !error ? 'All available history loaded.' : 'Open the picker to browse history.') }}</p>
|
||||
<p v-if="notice" class="gx-note" role="status" style="margin:6px 0 0;">{{ notice }}</p>
|
||||
<dialog ref="menu" :id="uid + '-dialog'" class="gx-select-menu gx-history-menu" :aria-labelledby="uid + '-title'" :aria-describedby="uid + '-help'"
|
||||
style="padding:0; overflow:hidden;" @cancel.prevent="close" @click="event => { if (event.target === $refs.menu) close() }" @keydown="menuKey">
|
||||
<div style="height:100%; display:flex; flex-direction:column; min-height:0;">
|
||||
<div style="padding:14px 16px; border-bottom:1px solid var(--outline-variant);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||
<strong :id="uid + '-title'">{{ releaseBranch ? 'Earlier StarPilot versions' : 'Earlier versions by day' }}</strong>
|
||||
<button ref="close" type="button" class="gx-btn gx-btn--tonal" aria-label="Close version history" style="min-width:44px; padding:8px;" @click="close">✕</button>
|
||||
</div>
|
||||
<p class="gx-note" style="margin:6px 0;" aria-live="polite">{{ range }}</p>
|
||||
<p v-if="notice" class="gx-note" role="status" style="margin:6px 0;">{{ notice }}</p>
|
||||
<p :id="uid + '-help'" class="gx-note" style="margin:0;">{{ releaseBranch ? 'Each version uses its newest build. Build dates are shown in your local time.' : 'Expand a day to choose a build. Build dates are shown in your local time.' }}</p>
|
||||
</div>
|
||||
<div ref="scroll" style="overflow-y:auto; overscroll-behavior:contain; min-height:0; flex:1; padding:6px;" :aria-busy="loading">
|
||||
<div v-if="releaseBranch" role="listbox" aria-label="StarPilot releases">
|
||||
<button v-for="commit in choices" :key="commit.sha" type="button" role="option" :data-value="commit.sha" :aria-selected="commit.sha === value" :disabled="disabled" @click="select(commit.sha)">
|
||||
<span style="min-width:0; overflow-wrap:anywhere;">{{ versionTitle(commit, true) }}</span>
|
||||
<i v-if="commit.sha === value" class="bi bi-check-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<section v-for="group in groups" :key="group.key" :data-day-group="group.key" style="border-bottom:1px solid var(--outline-variant);">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :data-day="group.key" :aria-expanded="expanded[group.key]" :aria-controls="uid + '-' + group.key"
|
||||
style="width:100%; display:flex; justify-content:space-between; gap:12px; text-align:left; padding:12px; border-radius:8px; margin:2px 0;"
|
||||
@click="expanded[group.key] = !expanded[group.key]">
|
||||
<span>{{ group.label }} <small style="opacity:.7;">· {{ group.commits.length }} {{ group.commits.length === 1 ? 'build' : 'builds' }}</small></span>
|
||||
<i :class="expanded[group.key] ? 'bi bi-chevron-up' : 'bi bi-chevron-down'" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="expanded[group.key]" :id="uid + '-' + group.key" role="listbox" :aria-label="group.label + ' versions'">
|
||||
<button v-for="commit in group.commits" :key="commit.sha" type="button" role="option" :data-value="commit.sha" :aria-selected="commit.sha === value" :disabled="disabled" @click="select(commit.sha)">
|
||||
<span style="min-width:0; overflow-wrap:anywhere;">
|
||||
<span>{{ versionTitle(commit, releaseBranch) }}</span>
|
||||
</span><i v-if="commit.sha === value" class="bi bi-check-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
<div style="padding:12px 16px; border-top:1px solid var(--outline-variant); flex-shrink:0;">
|
||||
<p v-if="error" class="gx-note gx-note--danger" role="alert" style="overflow-wrap:anywhere;">{{ error }}</p>
|
||||
<p v-if="loading" class="gx-note" role="status">Loading older history…</p>
|
||||
<button v-if="hasMore || error" type="button" class="gx-btn gx-btn--tonal" :aria-disabled="loading || disabled" style="width:100%; white-space:normal;" @click="loadMore">{{ error ? 'Retry history' : (releaseBranch ? 'Load older versions' : 'Load older days') }}</button>
|
||||
<p v-else-if="!loading" class="gx-note" role="status">{{ commits.length ? 'All available history loaded.' : 'No history available.' }}</p>
|
||||
<p v-if="hasMore && !error" class="gx-note" style="margin-bottom:0;">Keep loading to browse further back.</p>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { api, showSnackbar } from "../api.js"
|
||||
import { usePolling } from "../composables.js"
|
||||
import { GalaxyConfirm } from "../components/GalaxyModal.js"
|
||||
import { GalaxySection } from "../components/GalaxySection.js"
|
||||
import { GalaxySelect } from "../components/GalaxySelect.js"
|
||||
import { VersionHistoryPicker, versionTitle, releaseVersions } from "../components/VersionHistoryPicker.js"
|
||||
import { GxNotice } from "../components/GxNotice.js"
|
||||
|
||||
function shortCommit(commit) {
|
||||
@@ -16,12 +18,26 @@ function toPercent(value) {
|
||||
|
||||
export const SystemTools = {
|
||||
name: "SystemTools",
|
||||
components: { GalaxySection, GxNotice },
|
||||
components: { GalaxySection, GxNotice, GalaxySelect, VersionHistoryPicker },
|
||||
data() {
|
||||
return {
|
||||
branches: [],
|
||||
currentBranch: "",
|
||||
targetBranch: "",
|
||||
versionMode: "latest",
|
||||
selectedCommit: "",
|
||||
versionCommits: [],
|
||||
versionHead: "",
|
||||
versionPage: 0,
|
||||
versionHasMore: false,
|
||||
versionLoading: false,
|
||||
versionError: "",
|
||||
versionNotice: "",
|
||||
versionGeneration: 0,
|
||||
branchLoading: true,
|
||||
otherBranchesOpen: false,
|
||||
branchBusy: false,
|
||||
|
||||
isOnroad: false,
|
||||
fastStatus: null,
|
||||
checkedForUpdates: false,
|
||||
@@ -42,8 +58,27 @@ export const SystemTools = {
|
||||
this.poll.start()
|
||||
},
|
||||
mounted() { this.loadBranches(); this.loadProfiles(); this.loadTailscale() },
|
||||
beforeUnmount() { this.poll?.destroy() },
|
||||
beforeUnmount() { this.poll?.destroy(); this.resetVersions() },
|
||||
computed: {
|
||||
primaryBranchValue() {
|
||||
if (this.otherBranchesOpen) return "other:"
|
||||
return ["StarPilot", "Dom"].includes(this.targetBranch) ? this.targetBranch : ""
|
||||
},
|
||||
otherBranches() {
|
||||
const branches = this.branches.filter(branch => !["StarPilot", "Dom"].includes(branch))
|
||||
if (this.currentBranch && !["StarPilot", "Dom"].includes(this.currentBranch) && !branches.includes(this.currentBranch)) {
|
||||
branches.unshift(this.currentBranch)
|
||||
}
|
||||
return branches
|
||||
},
|
||||
branchSwitchBlocked() {
|
||||
return this.branchLoading || this.isOnroad || !!this.fastStatus?.isOnroad || !!this.fastStatus?.running || !!this.busy
|
||||
},
|
||||
versionChoices() { return this.targetBranch === "StarPilot" ? releaseVersions(this.versionCommits) : this.versionCommits },
|
||||
installVersionBlocked() {
|
||||
return this.branchSwitchBlocked || this.branchBusy || !this.branches.includes(this.targetBranch) ||
|
||||
(this.versionMode === "earlier" && (this.versionLoading || !/^[a-f0-9]{40}$/.test(this.selectedCommit) || !this.versionChoices.some(commit => commit.sha === this.selectedCommit)))
|
||||
},
|
||||
updateAvailable() { return this.checkedForUpdates && !!this.fastStatus?.updateAvailable && !this.fastStatus?.running },
|
||||
factoryResetStatus() {
|
||||
const s = this.fastStatus
|
||||
@@ -69,6 +104,10 @@ export const SystemTools = {
|
||||
const data = await api.getUpdateBranches()
|
||||
this.branches = Array.isArray(data?.branches) ? data.branches : []
|
||||
this.currentBranch = data?.currentBranch || ""
|
||||
if (!this.targetBranch) {
|
||||
this.targetBranch = this.currentBranch
|
||||
this.otherBranchesOpen = !!this.targetBranch && !["StarPilot", "Dom"].includes(this.targetBranch)
|
||||
}
|
||||
this.isOnroad = !!data?.isOnroad
|
||||
} catch (e) {
|
||||
showSnackbar("Failed to load update info.", "error")
|
||||
@@ -168,20 +207,123 @@ export const SystemTools = {
|
||||
showSnackbar("Reset failed.", "error")
|
||||
}
|
||||
},
|
||||
onBranchSelect(e) {
|
||||
onPrimaryBranchSelect(e) {
|
||||
const branch = e.target.value
|
||||
e.target.value = this.currentBranch || ""
|
||||
this.switchBranch(branch)
|
||||
if (this.branchSwitchBlocked || this.branchBusy) return
|
||||
this.otherBranchesOpen = branch === "other:"
|
||||
if (this.otherBranchesOpen) {
|
||||
// Other is navigation, never an install target.
|
||||
this.targetBranch = ""
|
||||
this.resetVersions()
|
||||
} else this.selectTargetBranch(branch)
|
||||
},
|
||||
async switchBranch(branch) {
|
||||
if (!branch || branch === this.currentBranch) return
|
||||
if (!(await GalaxyConfirm({ title: "Switch branch?", message: `Switch to ${branch} and update?`, confirmLabel: "Switch" }))) return
|
||||
onBranchSelect(e) { this.selectTargetBranch(e.target.value) },
|
||||
selectTargetBranch(branch) {
|
||||
if (!branch || !this.branches.includes(branch) || this.branchBusy || this.branchSwitchBlocked) return
|
||||
if (branch === this.targetBranch) return
|
||||
this.targetBranch = branch
|
||||
this.otherBranchesOpen = !["StarPilot", "Dom"].includes(branch)
|
||||
this.resetVersions()
|
||||
},
|
||||
resetVersions() {
|
||||
this.versionAbort?.abort()
|
||||
this.versionAbort = null
|
||||
this.versionGeneration++
|
||||
this.versionMode = "latest"
|
||||
this.selectedCommit = ""
|
||||
this.versionCommits = []
|
||||
this.versionHead = ""
|
||||
this.versionPage = 0
|
||||
this.versionHasMore = false
|
||||
this.versionLoading = false
|
||||
this.versionError = ""
|
||||
this.versionNotice = ""
|
||||
},
|
||||
async onVersionModeSelect(e) {
|
||||
if (this.branchSwitchBlocked || this.branchBusy) return
|
||||
const mode = e.target.value
|
||||
if (!["latest", "earlier"].includes(mode)) return
|
||||
this.resetVersions()
|
||||
this.versionMode = mode
|
||||
if (mode === "earlier") await this.loadVersions()
|
||||
},
|
||||
async loadVersions(more = false) {
|
||||
if (!this.targetBranch || !this.branches.includes(this.targetBranch) || this.versionLoading || this.versionMode !== "earlier" || (more && !this.versionHasMore)) return
|
||||
const branch = this.targetBranch
|
||||
const generation = this.versionGeneration
|
||||
let page = more ? this.versionPage + 1 : 1
|
||||
let head = more ? this.versionHead : ""
|
||||
const controller = new AbortController()
|
||||
this.versionAbort = controller
|
||||
this.versionLoading = true
|
||||
this.versionError = ""
|
||||
const originalCount = this.versionChoices.length
|
||||
const budget = more && branch === "StarPilot" ? 4 : 1
|
||||
try {
|
||||
await api.setUpdateBranch(branch)
|
||||
showSnackbar(`Switching to ${branch}...`)
|
||||
for (let scanned = 0; scanned < budget; scanned++) {
|
||||
const data = await api.getUpdateVersions(branch, {page, head, signal: controller.signal})
|
||||
if (generation !== this.versionGeneration || controller.signal.aborted) return
|
||||
if (data?.branch !== branch || data?.page !== page || !/^[a-f0-9]{40}$/.test(data?.head || "") || (head && data.head !== head) || !Array.isArray(data?.commits)) throw new Error("Version history changed. Choose Latest and try again.")
|
||||
const commits = data.commits.filter(commit => /^[a-f0-9]{40}$/.test(commit?.sha || ""))
|
||||
this.versionCommits = page > 1 ? [...this.versionCommits, ...commits.filter(commit => !this.versionCommits.some(existing => existing.sha === commit.sha))] : commits
|
||||
if (data.cached) {
|
||||
const saved = new Date(data.cachedAt)
|
||||
const when = Number.isFinite(saved.getTime()) ? " from " + saved.toLocaleString() : ""
|
||||
this.versionNotice = "Showing saved history" + when + ". Installation still needs an online check."
|
||||
} else if (page === 1) this.versionNotice = ""
|
||||
this.versionHead = data.head
|
||||
this.versionPage = page
|
||||
this.versionHasMore = !!data.hasMore && commits.length > 0
|
||||
if (!this.versionCommits.length) this.versionError = "No versions are available for this branch. Choose Latest or another branch."
|
||||
if (!this.versionHasMore || this.versionChoices.length > originalCount) break
|
||||
page++
|
||||
head = this.versionHead
|
||||
}
|
||||
} catch (e) {
|
||||
if (generation !== this.versionGeneration || controller.signal.aborted) return
|
||||
this.versionError = e?.message || "Failed to load version history. Try again."
|
||||
} finally {
|
||||
if (generation === this.versionGeneration) {
|
||||
this.versionLoading = false
|
||||
this.versionAbort = null
|
||||
}
|
||||
}
|
||||
},
|
||||
versionDate(date) {
|
||||
const value = new Date(date)
|
||||
return Number.isNaN(value.getTime()) ? "Date unavailable" : value.toLocaleDateString(undefined, {year: "numeric", month: "short", day: "numeric"})
|
||||
},
|
||||
async returnToLatest() {
|
||||
const branch = this.fastStatus?.versionPin?.branch
|
||||
if (this.branchSwitchBlocked || this.branchBusy || !this.branches.includes(branch)) return
|
||||
this.selectTargetBranch(branch)
|
||||
this.resetVersions()
|
||||
await this.installSelectedVersion()
|
||||
},
|
||||
async installSelectedVersion() {
|
||||
if (this.installVersionBlocked) return
|
||||
const branch = this.targetBranch
|
||||
const commit = this.versionMode === "latest" ? "latest" : this.selectedCommit
|
||||
const generation = this.versionGeneration
|
||||
this.branchBusy = true
|
||||
try {
|
||||
const selected = this.versionCommits.find(item => item.sha === commit)
|
||||
const version = commit === "latest" ? "Latest" : `${versionTitle(selected, branch === "StarPilot")}\nCommit: ${commit}`
|
||||
const policy = commit === "latest" ? "Automatic updates will remain off after installation. You can enable them in settings." : "Automatic updates will be paused for this earlier version."
|
||||
if (!(await GalaxyConfirm({title: "Install selected version?", message: `Branch: ${branch}\nVersion: ${version}\n\n${policy}\n\nThis replaces the current software. Settings and statistics are kept, and local code changes are backed up. Older versions may remove this picker; an SSH recovery copy is saved on the device.\n\nYour device will reboot when installation finishes.`, confirmLabel: "Install & Reboot", danger: true}))) return
|
||||
// Refresh driving/updater state after the user has reviewed the target.
|
||||
await this.loadFastStatus({throwOnError: true})
|
||||
if (this.branchSwitchBlocked || this.targetBranch !== branch || generation !== this.versionGeneration) {
|
||||
showSnackbar("Installation is unavailable while driving or updating, or the selection has changed.", "error")
|
||||
return
|
||||
}
|
||||
const result = await api.installUpdateVersion(branch, commit)
|
||||
showSnackbar(result?.message || `Installing ${version} on ${branch}...`)
|
||||
await this.loadFastStatus()
|
||||
} catch (e) {
|
||||
showSnackbar(e?.message || "Switch failed.", "error")
|
||||
showSnackbar(e?.message || "Installation failed.", "error")
|
||||
} finally {
|
||||
this.branchBusy = false
|
||||
}
|
||||
},
|
||||
async checkUpdates() {
|
||||
@@ -342,7 +484,7 @@ export const SystemTools = {
|
||||
<span v-else class="gx-chip">Not checked</span>
|
||||
</div>
|
||||
<div style="padding: var(--sp-3); display:grid; gap:6px;">
|
||||
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Branch</span><span class="gx-row__value">{{ fastStatus.branch || currentBranch || '—' }}</span></div>
|
||||
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Installed branch</span><span class="gx-row__value">{{ fastStatus.branch || currentBranch || '—' }}</span></div>
|
||||
<div v-if="fastStatus.running" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Stage</span><span class="gx-row__value">{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}</span></div>
|
||||
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Local</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.localCommit) }}</span></div>
|
||||
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Remote</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.remoteCommit) }}</span></div>
|
||||
@@ -383,12 +525,48 @@ export const SystemTools = {
|
||||
</div>
|
||||
|
||||
<div class="gx-card" style="margin-bottom:12px;">
|
||||
<div class="gx-section__header"><i class="bi bi-git-branch"></i><span class="gx-section__title">Switch Branch</span></div>
|
||||
<div class="gx-section__header"><i class="bi bi-git-branch"></i><span class="gx-section__title">Install a Version</span></div>
|
||||
<div style="padding: var(--sp-3);">
|
||||
<select class="gx-field gx-field--full" :disabled="!!isOnroad" @change="onBranchSelect">
|
||||
<option v-if="!branches.length" value="">No branches available</option>
|
||||
<option v-for="b in branches" :key="b" :value="b" :selected="b === currentBranch">{{ b === currentBranch ? b + ' (current)' : b }}</option>
|
||||
</select>
|
||||
<p class="gx-note" style="margin-top:0; overflow-wrap:anywhere;">Installed branch: <strong>{{ currentBranch || 'Unknown' }}</strong></p>
|
||||
<GalaxySelect id="gx-primary-branch" class="gx-field gx-field--full" aria-label="Target branch"
|
||||
:value="primaryBranchValue" :disabled="branchSwitchBlocked || branchBusy" @change="onPrimaryBranchSelect">
|
||||
<option value="" disabled>Select a branch</option>
|
||||
<option value="StarPilot" data-collapsed-label="StarPilot" data-description="Stable releases. Recommended for most users." :disabled="!branches.includes('StarPilot')">StarPilot — Release</option>
|
||||
<option value="Dom" data-collapsed-label="Dom" data-description="Latest features and fixes under development. Updates regularly and may introduce bugs." :disabled="!branches.includes('Dom')">Dom — Development</option>
|
||||
<option value="other:">Other branches…</option>
|
||||
</GalaxySelect>
|
||||
<div v-if="otherBranchesOpen" style="margin-top:var(--sp-3); padding-left:var(--sp-3); border-left:2px solid var(--outline-variant);">
|
||||
<label for="gx-other-branch" class="gx-row__label">Other branches</label>
|
||||
<p class="gx-note">Additional branches from this installation's repository.</p>
|
||||
<GalaxySelect id="gx-other-branch" class="gx-field gx-field--full" aria-label="Other branches"
|
||||
:value="otherBranches.includes(targetBranch) ? targetBranch : ''" :disabled="branchSwitchBlocked || branchBusy" @change="onBranchSelect">
|
||||
<option value="" disabled>{{ otherBranches.length ? 'Select another branch' : 'No other branches available' }}</option>
|
||||
<option v-for="b in otherBranches" :key="b" :value="b">{{ b === currentBranch ? b + ' (current)' : b }}</option>
|
||||
</GalaxySelect>
|
||||
</div>
|
||||
<p v-if="!branchLoading && !branches.length" class="gx-note">No branch list available. Reload when connected to check available branches.</p>
|
||||
<div v-if="targetBranch" style="margin-top:var(--sp-3); display:grid; gap:8px; min-width:0;">
|
||||
<label for="gx-version-mode" class="gx-row__label">Version</label>
|
||||
<GalaxySelect id="gx-version-mode" class="gx-field gx-field--full" aria-label="Version" :value="versionMode"
|
||||
:disabled="branchSwitchBlocked || branchBusy || !branches.includes(targetBranch)" @change="onVersionModeSelect">
|
||||
<option value="latest">Latest</option>
|
||||
<option value="earlier">Choose earlier…</option>
|
||||
</GalaxySelect>
|
||||
<template v-if="versionMode === 'earlier'">
|
||||
<VersionHistoryPicker id="gx-version-commit" :value="selectedCommit" :commits="versionCommits"
|
||||
:release-branch="targetBranch === 'StarPilot'" :loading="versionLoading" :has-more="versionHasMore" :error="versionError" :notice="versionNotice"
|
||||
:disabled="branchSwitchBlocked || branchBusy" @change="selectedCommit = $event.target.value"
|
||||
@loadmore="loadVersions(versionCommits.length > 0 && versionHasMore)" />
|
||||
</template>
|
||||
<p v-if="!branches.includes(targetBranch)" class="gx-note">This installed branch is no longer listed by the repository. Select an available target branch to install a version.</p>
|
||||
<button type="button" class="gx-btn" :disabled="installVersionBlocked" @click="installSelectedVersion">
|
||||
<i v-if="branchBusy" class="bi bi-arrow-repeat gx-spin"></i>{{ branchBusy ? 'Starting installation…' : 'Install selected version' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="fastStatus?.versionPin" class="gx-note" style="margin-top:var(--sp-3); overflow-wrap:anywhere;">
|
||||
<p>Pinned version: <strong>{{ fastStatus.versionPin.branch }} · {{ shortCommit(fastStatus.versionPin.commit) }}</strong><br>Automatic updates were paused at installation.</p>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="branchSwitchBlocked || branchBusy || !branches.includes(fastStatus.versionPin.branch)" @click="returnToLatest">Return to Latest</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import fs from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
const root = process.argv[2] || new URL('../../../../', import.meta.url).pathname
|
||||
const js = root + '/starpilot/system/the_galaxy/assets/mobile/js/'
|
||||
const source = fs.readFileSync(js + 'views/SystemTools.js', 'utf8')
|
||||
const SHA = 'a'.repeat(40), HEAD = 'b'.repeat(40)
|
||||
function fixture() {
|
||||
const calls = [], confirmations = [], messages = [], reads = []
|
||||
let confirm = async () => true
|
||||
const api = {
|
||||
setUpdateBranch: async branch => calls.push({legacy: branch}),
|
||||
installUpdateVersion: async (branch, commit) => calls.push({branch, commit}),
|
||||
getUpdateFastStatus: async () => ({running: false, isOnroad: false, versionPin: null}),
|
||||
getUpdateVersions: async (branch, options) => {reads.push({branch, ...options}); return {branch, head: HEAD, page: options.page, hasMore: options.page === 1, commits: [{sha: options.page === 1 ? SHA : 'c'.repeat(40), date: '2026-09-10T12:00:00Z', subject: 'Fix launch'}]}},
|
||||
}
|
||||
const context = vm.createContext({api, AbortController, showSnackbar: (...args) => messages.push(args), GalaxySection: {}, GalaxySelect: {}, GxNotice: {}, GalaxyConfirm: async options => {confirmations.push(options); return confirm()}})
|
||||
vm.runInContext(fs.readFileSync(js + 'components/VersionHistoryPicker.js', 'utf8').replace(/export /g, ''), context)
|
||||
vm.runInContext(source.replace(/^import .*$/gm, '').replace('export const SystemTools =', 'globalThis.component ='), context)
|
||||
const component = context.component
|
||||
const instance = {...component.data(), ...component.methods, branches: ['Dom', 'SunnyPilot', 'StarPilot', 'feature/test'], currentBranch: 'feature/local', branchLoading: false}
|
||||
for (const [key, getter] of Object.entries(component.computed)) Object.defineProperty(instance, key, {get: () => getter.call(instance)})
|
||||
return {instance, calls, confirmations, messages, reads, api, setConfirm: fn => {confirm = fn}}
|
||||
}
|
||||
test('primary and Other choices stage a target without installing or confirming', async () => {
|
||||
const {instance, calls, confirmations} = fixture()
|
||||
await instance.onPrimaryBranchSelect({target: {value: 'Dom'}})
|
||||
assert.equal(instance.targetBranch, 'Dom')
|
||||
await instance.onPrimaryBranchSelect({target: {value: 'other:'}})
|
||||
assert.equal(instance.otherBranchesOpen, true)
|
||||
await instance.onBranchSelect({target: {value: 'SunnyPilot'}})
|
||||
assert.equal(instance.targetBranch, 'SunnyPilot')
|
||||
assert.equal(instance.currentBranch, 'feature/local')
|
||||
assert.deepEqual(calls, [])
|
||||
assert.deepEqual(confirmations, [])
|
||||
})
|
||||
test('current custom branch remains visible when absent from remote', () => {
|
||||
const {instance} = fixture()
|
||||
assert.deepEqual(Array.from(instance.otherBranches), ['feature/local', 'SunnyPilot', 'feature/test'])
|
||||
})
|
||||
test('every remote branch can install latest only through explicit confirmation', async () => {
|
||||
for (const branch of ['StarPilot', 'Dom', 'SunnyPilot', 'feature/test']) {
|
||||
const {instance, calls, confirmations} = fixture()
|
||||
instance.selectTargetBranch(branch)
|
||||
await instance.installSelectedVersion()
|
||||
assert.deepEqual(calls, [{branch, commit: 'latest'}])
|
||||
assert.equal(confirmations.length, 1)
|
||||
assert.ok(confirmations[0].message.includes(branch))
|
||||
assert.ok(confirmations[0].message.includes('Latest'))
|
||||
}
|
||||
})
|
||||
test('historical install requires a loaded full SHA and cancellation writes nothing', async () => {
|
||||
const {instance, calls, confirmations, setConfirm} = fixture()
|
||||
instance.selectTargetBranch('Dom')
|
||||
await instance.onVersionModeSelect({target: {value: 'earlier'}})
|
||||
instance.selectedCommit = 'deadbeef'
|
||||
await instance.installSelectedVersion()
|
||||
assert.deepEqual(confirmations, [])
|
||||
instance.selectedCommit = SHA
|
||||
setConfirm(async () => false)
|
||||
await instance.installSelectedVersion()
|
||||
assert.deepEqual(calls, [])
|
||||
setConfirm(async () => true)
|
||||
await instance.installSelectedVersion()
|
||||
assert.deepEqual(calls, [{branch: 'Dom', commit: SHA}])
|
||||
assert.ok(confirmations.at(-1).message.includes(SHA))
|
||||
})
|
||||
test('pagination uses the original branch head and changing branch resets to Latest', async () => {
|
||||
const {instance, reads} = fixture()
|
||||
instance.selectTargetBranch('Dom')
|
||||
await instance.onVersionModeSelect({target: {value: 'earlier'}})
|
||||
instance.selectedCommit = SHA
|
||||
await instance.loadVersions(true)
|
||||
assert.equal(reads[1].page, 2)
|
||||
assert.equal(reads[1].head, HEAD)
|
||||
assert.equal(instance.versionCommits.length, 2)
|
||||
instance.selectTargetBranch('feature/test')
|
||||
assert.equal(instance.versionMode, 'latest')
|
||||
assert.equal(instance.selectedCommit, '')
|
||||
assert.equal(instance.versionCommits.length, 0)
|
||||
assert.equal(instance.versionHead, '')
|
||||
})
|
||||
test('late history response after branch change cannot repopulate history', async () => {
|
||||
const {instance, api} = fixture()
|
||||
let resolve, signal
|
||||
api.getUpdateVersions = (branch, options) => {signal = options.signal; return new Promise(done => {resolve = done})}
|
||||
instance.selectTargetBranch('Dom')
|
||||
const pending = instance.onVersionModeSelect({target: {value: 'earlier'}})
|
||||
instance.selectTargetBranch('StarPilot')
|
||||
assert.equal(signal.aborted, true)
|
||||
resolve({branch: 'Dom', head: HEAD, page: 1, hasMore: false, commits: [{sha: SHA}]})
|
||||
await pending
|
||||
assert.equal(instance.versionCommits.length, 0)
|
||||
assert.equal(instance.targetBranch, 'StarPilot')
|
||||
assert.equal(instance.versionLoading, false)
|
||||
})
|
||||
test('failed or missing history explains the issue and never enables historical install', async () => {
|
||||
const {instance, api} = fixture()
|
||||
instance.selectTargetBranch('Dom')
|
||||
api.getUpdateVersions = async () => {throw new Error('Branch no longer available')}
|
||||
await instance.onVersionModeSelect({target: {value: 'earlier'}})
|
||||
assert.equal(instance.versionError, 'Branch no longer available')
|
||||
assert.equal(instance.installVersionBlocked, true)
|
||||
api.getUpdateVersions = async () => ({branch: 'Dom', head: HEAD, page: 1, hasMore: false, commits: []})
|
||||
await instance.loadVersions()
|
||||
assert.match(instance.versionError, /No versions/)
|
||||
})
|
||||
test('driving, running, or busy state blocks install and is rechecked after confirmation', async () => {
|
||||
for (const state of [{isOnroad: true}, {fastStatus: {running: true}}, {busy: 'check'}]) {
|
||||
const {instance, calls, confirmations} = fixture()
|
||||
instance.selectTargetBranch('Dom')
|
||||
Object.assign(instance, state)
|
||||
await instance.installSelectedVersion()
|
||||
assert.deepEqual(calls, [])
|
||||
assert.deepEqual(confirmations, [])
|
||||
}
|
||||
const {instance, calls, api, setConfirm} = fixture()
|
||||
instance.selectTargetBranch('Dom')
|
||||
setConfirm(async () => {api.getUpdateFastStatus = async () => ({isOnroad: true}); return true})
|
||||
await instance.installSelectedVersion()
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
test('concurrent install confirmation and failed status refresh cannot submit', async () => {
|
||||
const {instance, calls, confirmations, api, setConfirm} = fixture()
|
||||
instance.selectTargetBranch('Dom')
|
||||
let release
|
||||
setConfirm(() => new Promise(resolve => {release = resolve}))
|
||||
const pending = instance.installSelectedVersion()
|
||||
await instance.installSelectedVersion()
|
||||
assert.equal(confirmations.length, 1)
|
||||
api.getUpdateFastStatus = async () => {throw new Error('offline')}
|
||||
release(true)
|
||||
await pending
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
test('return to Latest uses pinned branch and confirms without changing automatic update settings', async () => {
|
||||
const {instance, calls, confirmations} = fixture()
|
||||
instance.fastStatus = {versionPin: {branch: 'feature/test', commit: SHA, installedAt: '2026-09-10'}}
|
||||
await instance.returnToLatest()
|
||||
assert.deepEqual(calls, [{branch: 'feature/test', commit: 'latest'}])
|
||||
assert.ok(confirmations[0].message.includes('remain off'))
|
||||
})
|
||||
test('API preserves branch encoding, pagination head, abort signal and confirmed install body', async () => {
|
||||
const calls = []
|
||||
const context = vm.createContext({URLSearchParams, fetch: async (url, init) => {calls.push({url, init}); return {ok: true, json: async () => ({})}}})
|
||||
vm.runInContext(fs.readFileSync(js + 'api.js', 'utf8').replace(/export /g, '') + '\nglobalThis.client = api', context)
|
||||
const signal = new AbortController().signal
|
||||
await context.client.getUpdateVersions('feature/a&b', {page: 2, head: HEAD, signal})
|
||||
const url = new URL(calls[0].url, 'https://example.test')
|
||||
assert.equal(url.searchParams.get('branch'), 'feature/a&b')
|
||||
assert.equal(url.searchParams.get('page'), '2')
|
||||
assert.equal(url.searchParams.get('head'), HEAD)
|
||||
assert.equal(calls[0].init.signal, signal)
|
||||
await context.client.installUpdateVersion('feature/a&b', SHA)
|
||||
assert.equal(calls[1].url, '/api/update/version')
|
||||
assert.equal(calls[1].init.method, 'POST')
|
||||
assert.deepEqual(JSON.parse(calls[1].init.body), {branch: 'feature/a&b', commit: SHA, confirmed: true})
|
||||
})
|
||||
test('GalaxySelect reads optional option descriptions without adding them to collapsed labels', () => {
|
||||
const context = vm.createContext({document: {getElementById() {}}})
|
||||
vm.runInContext(fs.readFileSync(js + 'components/GalaxySelect.js', 'utf8').replace('export const GalaxySelect =', 'globalThis.component ='), context)
|
||||
const native = {value: 'Dom', options: [{value: 'Dom', label: 'Dom', dataset: {description: 'Development description'}}, {value: 'StarPilot', label: 'StarPilot'}], selectedOptions: [{label: 'Dom'}]}
|
||||
const instance = {...context.component.data(), $refs: {native}, $attrs: {}, current: 'Dom'}
|
||||
context.component.methods.sync.call(instance)
|
||||
assert.equal(instance.items[0].description, 'Development description')
|
||||
assert.equal(instance.items[1].description, '')
|
||||
assert.equal(instance.label, 'Dom')
|
||||
})
|
||||
|
||||
test('unavailable branch and navigation values cannot become install targets', async () => {
|
||||
const {instance, calls, confirmations} = fixture()
|
||||
for (const branch of ['other:', '', 'deleted-branch']) instance.selectTargetBranch(branch)
|
||||
await instance.installSelectedVersion()
|
||||
assert.equal(instance.targetBranch, '')
|
||||
assert.deepEqual(calls, [])
|
||||
assert.deepEqual(confirmations, [])
|
||||
})
|
||||
test('mismatched paginated history cannot mix another branch head into the selected version', async () => {
|
||||
const {instance, api} = fixture()
|
||||
instance.selectTargetBranch('Dom')
|
||||
await instance.onVersionModeSelect({target: {value: 'earlier'}})
|
||||
api.getUpdateVersions = async () => ({branch: 'Dom', head: 'd'.repeat(40), page: 2, hasMore: false, commits: [{sha: 'e'.repeat(40)}]})
|
||||
await instance.loadVersions(true)
|
||||
assert.equal(instance.versionCommits.length, 1)
|
||||
assert.equal(instance.versionHead, HEAD)
|
||||
assert.match(instance.versionError, /history changed/)
|
||||
})
|
||||
test('switching back to Latest invalidates history and selected revision', async () => {
|
||||
const {instance, api} = fixture()
|
||||
let reject
|
||||
api.getUpdateVersions = async () => new Promise((resolve, fail) => {reject = fail})
|
||||
instance.selectTargetBranch('Dom')
|
||||
const pending = instance.onVersionModeSelect({target: {value: 'earlier'}})
|
||||
await instance.onVersionModeSelect({target: {value: 'latest'}})
|
||||
reject(new Error('Old network failure'))
|
||||
await pending
|
||||
assert.equal(instance.versionMode, 'latest')
|
||||
assert.equal(instance.versionError, '')
|
||||
assert.equal(instance.versionLoading, false)
|
||||
assert.equal(instance.installVersionBlocked, false)
|
||||
})
|
||||
|
||||
test('release confirmation shows a friendly version and the exact installation SHA', async () => {
|
||||
const {instance, calls, confirmations} = fixture()
|
||||
instance.selectTargetBranch('StarPilot')
|
||||
await instance.onVersionModeSelect({target: {value: 'earlier'}})
|
||||
instance.versionCommits[0].version = '6.7.7'
|
||||
instance.selectedCommit = SHA
|
||||
await instance.installSelectedVersion()
|
||||
assert.match(confirmations[0].message, /Version: 6\.7\.7/)
|
||||
assert.ok(confirmations[0].message.includes('Commit: ' + SHA))
|
||||
assert.deepEqual(calls, [{branch: 'StarPilot', commit: SHA}])
|
||||
})
|
||||
|
||||
test('saved history is labelled and retained across pagination, then reset on branch change', async () => {
|
||||
const {instance,api,calls} = fixture()
|
||||
const normal=api.getUpdateVersions
|
||||
api.getUpdateVersions=async (...args)=>({...await normal(...args), cached:true, cachedAt:'2026-09-11T12:00:00Z'})
|
||||
instance.selectTargetBranch('Dom')
|
||||
await instance.onVersionModeSelect({target:{value:'earlier'}})
|
||||
assert.match(instance.versionNotice,/saved history/i)
|
||||
assert.match(instance.versionNotice,/online check/i)
|
||||
api.getUpdateVersions=normal
|
||||
await instance.loadVersions(true)
|
||||
assert.match(instance.versionNotice,/saved history/i)
|
||||
assert.deepEqual(calls,[])
|
||||
instance.selectTargetBranch('StarPilot')
|
||||
assert.equal(instance.versionNotice,'')
|
||||
})
|
||||
test('fresh history does not show a saved-history notice', async () => {
|
||||
const {instance} = fixture()
|
||||
instance.selectTargetBranch('Dom')
|
||||
await instance.onVersionModeSelect({target:{value:'earlier'}})
|
||||
assert.equal(instance.versionNotice,'')
|
||||
})
|
||||
|
||||
test('release pagination searches duplicate pages for the next version and installs only its newest build', async () => {
|
||||
const {instance,api,calls}=fixture(), pages=[]
|
||||
api.getUpdateVersions=async(branch,{page})=>{pages.push(page);return {branch,head:HEAD,page,hasMore:page<5,commits:[{sha:String(page).repeat(40),version:page<4?'6.7.7':'6.7.6',date:'2026-09-11T12:00:00Z',subject:'Build'}]}}
|
||||
instance.selectTargetBranch('StarPilot');await instance.onVersionModeSelect({target:{value:'earlier'}})
|
||||
await instance.loadVersions(true)
|
||||
assert.deepEqual(pages,[1,2,3,4])
|
||||
assert.deepEqual(Array.from(instance.versionChoices,c=>c.sha),['1'.repeat(40),'4'.repeat(40)])
|
||||
instance.selectedCommit='2'.repeat(40);assert.equal(instance.installVersionBlocked,true)
|
||||
instance.selectedCommit='4'.repeat(40);await instance.installSelectedVersion()
|
||||
assert.deepEqual(calls,[{branch:'StarPilot',commit:'4'.repeat(40)}])
|
||||
})
|
||||
test('a release search has a bounded request budget and preserves progress when older history is unavailable', async () => {
|
||||
const {instance,api}=fixture();let requests=0
|
||||
api.getUpdateVersions=async(branch,{page})=>{requests++;return {branch,head:HEAD,page,hasMore:true,commits:[{sha:page.toString(16).repeat(40),version:'6.7.7',date:'2026-09-11T12:00:00Z',subject:'Build'}]}}
|
||||
instance.selectTargetBranch('StarPilot');await instance.onVersionModeSelect({target:{value:'earlier'}})
|
||||
await instance.loadVersions(true);assert.equal(requests,5);assert.equal(instance.versionPage,5)
|
||||
api.getUpdateVersions=async()=>{throw Error('GitHub rate limit')}
|
||||
await instance.loadVersions(true)
|
||||
assert.equal(instance.versionPage,5);assert.equal(instance.versionChoices.length,1);assert.match(instance.versionError,/rate limit/)
|
||||
})
|
||||
|
||||
test('changing branch during a multi-page release search cancels remaining pages and rejects stale results', async () => {
|
||||
const {instance,api}=fixture();let finish,requests=0
|
||||
api.getUpdateVersions=async(branch,{page})=>{requests++;return {branch,head:HEAD,page,hasMore:true,commits:[{sha:'1'.repeat(40),version:'6.7.7',date:'2026-09-11T12:00:00Z',subject:'Build'}]}}
|
||||
instance.selectTargetBranch('StarPilot');await instance.onVersionModeSelect({target:{value:'earlier'}})
|
||||
api.getUpdateVersions=(branch,{page})=>{requests++;return new Promise(resolve=>{finish=()=>resolve({branch,head:HEAD,page,hasMore:true,commits:[{sha:'2'.repeat(40),version:'6.7.7',date:'2026-09-11T12:00:00Z',subject:'Build'}]})})}
|
||||
const pending=instance.loadVersions(true)
|
||||
instance.selectTargetBranch('Dom');finish();await pending
|
||||
assert.equal(requests,2);assert.equal(instance.versionCommits.length,0);assert.equal(instance.versionLoading,false)
|
||||
})
|
||||
@@ -0,0 +1,794 @@
|
||||
import importlib.util
|
||||
import io
|
||||
from http.client import IncompleteRead
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from urllib.request import Request
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
MODULE = Path(__file__).resolve().parents[1] / 'version_history.py'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def history(tmp_path, monkeypatch):
|
||||
assert MODULE.exists(), 'The branch history implementation must exist'
|
||||
spec = importlib.util.spec_from_file_location('version_history_under_test', MODULE)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
monkeypatch.setattr(module, "SNAPSHOT_CACHE_DIR", tmp_path / "history-cache", raising=False)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path):
|
||||
subprocess.run(['git', 'init', '-q', str(tmp_path)], check=True)
|
||||
subprocess.run(['git', '-C', str(tmp_path), 'remote', 'add', 'origin', 'https://github.com/firestar5683/openpilot.git'], check=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def commit(n):
|
||||
return {'sha': f'{n:040x}', 'commit': {'message': f'Change {n}\n\nDetails', 'committer': {'date': '2026-09-11T12:00:00Z'}}}
|
||||
|
||||
|
||||
class GitHub:
|
||||
def __init__(self):
|
||||
self.heads = {'main': 120, 'feature/slash': 60}
|
||||
self.requests = []
|
||||
self.invalid_compare = None
|
||||
|
||||
def __call__(self, url):
|
||||
self.requests.append(url)
|
||||
parsed = urlparse(url)
|
||||
assert parsed.scheme == 'https' and parsed.netloc == 'api.github.com'
|
||||
assert parsed.path.startswith('/repos/firestar5683/openpilot/')
|
||||
path = parsed.path.split('/openpilot/')[1]
|
||||
if path.startswith('branches/'):
|
||||
branch = unquote(path[len('branches/'):])
|
||||
return {'name': branch, 'commit': {'sha': f'{self.heads[branch]:040x}'}}
|
||||
if path.startswith('compare/'):
|
||||
base, head = (int(part, 16) for part in path[len('compare/'):].split('...'))
|
||||
return self.invalid_compare or {'status': 'identical' if base == head else ('ahead' if base < head else 'behind'),
|
||||
'merge_base_commit': {'sha': f'{min(base, head):040x}'}}
|
||||
assert path == 'commits'
|
||||
query = parse_qs(parsed.query)
|
||||
head, page, size = int(query['sha'][0], 16), int(query['page'][0]), int(query['per_page'][0])
|
||||
start = head - (page - 1) * size
|
||||
return [commit(n) for n in range(start, max(0, start - size), -1)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(history, monkeypatch):
|
||||
fake = GitHub()
|
||||
monkeypatch.setattr(history, '_get_json', fake)
|
||||
return fake
|
||||
|
||||
|
||||
def test_lists_25_commits_and_keeps_page_head_when_branch_advances(history, repo, api):
|
||||
first = history.list_versions(repo, 'main')
|
||||
assert first == {'branch': 'main', 'head': f'{120:040x}', 'page': 1, 'hasMore': True,
|
||||
'commits': [{'sha': f'{n:040x}', 'subject': f'Change {n}', 'date': '2026-09-11T12:00:00Z'} for n in range(120, 95, -1)]}
|
||||
api.heads['main'] = 121
|
||||
second = history.list_versions(repo, 'main', page=2, head=first['head'])
|
||||
assert second['head'] == first['head']
|
||||
assert second['commits'][0]['sha'] == f'{95:040x}'
|
||||
last = history.list_versions(repo, 'main', page=5, head=first['head'])
|
||||
assert len(last['commits']) == 20 and last['hasMore'] is False
|
||||
|
||||
|
||||
def test_slash_branch_and_switch_do_not_reuse_other_branch_head(history, repo, api):
|
||||
history.list_versions(repo, 'main')
|
||||
slash = history.list_versions(repo, 'feature/slash')
|
||||
assert slash['branch'] == 'feature/slash' and slash['head'] == f'{60:040x}'
|
||||
with pytest.raises(history.VersionHistoryError, match='branch'):
|
||||
history.list_versions(repo, 'feature/slash', page=2, head=f'{120:040x}')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('branch', ['', '-main', '../main', 'main~1', 'main..old', 'a b', '@{-1}', 'a\nb', None])
|
||||
def test_invalid_branches_never_reach_network(history, repo, api, branch):
|
||||
with pytest.raises(history.VersionHistoryError, match='branch'):
|
||||
history.list_versions(repo, branch)
|
||||
assert not api.requests
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', ['abcd', 'g' * 40, 'a' * 41, 'HEAD', '--help', None])
|
||||
def test_invalid_exact_commit_is_rejected(history, repo, api, value):
|
||||
with pytest.raises(history.VersionHistoryError, match='commit'):
|
||||
history.resolve_version(repo, 'main', value)
|
||||
assert not api.requests
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', ['git@github.com:firestar5683/openpilot.git', 'ssh://git@github.com/firestar5683/openpilot.git', 'https://github.com/firestar5683/openpilot'])
|
||||
def test_github_remote_formats(history, repo, api, url):
|
||||
subprocess.run(['git', '-C', str(repo), 'remote', 'set-url', 'origin', url], check=True)
|
||||
assert history.resolve_version(repo, 'main', 'latest')['commit'] == f'{120:040x}'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', ['https://gitlab.com/owner/repo.git', 'https://github.com.evil.test/owner/repo', '/local/repo', 'https://user:secret@github.com/owner/repo', 'https://github.com/owner/repo?x=1', 'https://github.com/owner/repo/extra'])
|
||||
def test_rejects_untrusted_origin_without_network_or_credential_disclosure(history, repo, api, url):
|
||||
subprocess.run(['git', '-C', str(repo), 'remote', 'set-url', 'origin', url], check=True)
|
||||
with pytest.raises(history.VersionHistoryError, match='origin') as exc:
|
||||
history.list_versions(repo, 'main')
|
||||
assert 'secret' not in str(exc.value) and not api.requests
|
||||
|
||||
|
||||
def test_resolve_refreshes_cached_head_and_validates_pin(history, repo, api):
|
||||
history.list_versions(repo, 'main')
|
||||
api.heads['main'] = 121
|
||||
assert history.resolve_version(repo, 'main', 'latest') == {'branch': 'main', 'commit': f'{121:040x}', 'head': f'{121:040x}', 'pinned': False}
|
||||
assert history.resolve_version(repo, 'main', f'{100:040x}') == {'branch': 'main', 'commit': f'{100:040x}', 'head': f'{121:040x}', 'pinned': True}
|
||||
assert history.resolve_version(repo, 'main', f'{121:040x}')['pinned'] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize('comparison', [{'status': 'diverged', 'merge_base_commit': {'sha': f'{50:040x}'}}, {'status': 'ahead', 'merge_base_commit': {'sha': f'{50:040x}'}}, {}, {'status': 'behind', 'merge_base_commit': {'sha': f'{100:040x}'}}])
|
||||
def test_exact_pin_rejects_cross_branch_or_invalid_ancestry(history, repo, api, comparison):
|
||||
api.invalid_compare = comparison
|
||||
if not comparison:
|
||||
api.invalid_compare = {'status': None}
|
||||
with pytest.raises(history.VersionHistoryError, match='branch|GitHub'):
|
||||
history.resolve_version(repo, 'main', f'{100:040x}')
|
||||
|
||||
|
||||
def test_stale_head_after_branch_rewrite_is_rejected_after_browser_ttl(history, repo, api, monkeypatch):
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
|
||||
old = history.list_versions(repo, 'main')['head']
|
||||
api.heads['main'] = 80
|
||||
# Browsing can reuse a head for 60 seconds; installation always refreshes.
|
||||
assert history.list_versions(repo, 'main', page=2, head=old)['head'] == old
|
||||
clock[0] += 61
|
||||
with pytest.raises(history.VersionHistoryError, match='branch'):
|
||||
history.list_versions(repo, 'main', page=2, head=old)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kwargs', [{'page': 0}, {'page': True}, {'page': 1.5}, {'page': '2'}, {'page': 2}, {'head': 'HEAD'}])
|
||||
def test_rejects_invalid_or_unpinned_pagination(history, repo, api, kwargs):
|
||||
with pytest.raises(history.VersionHistoryError):
|
||||
history.list_versions(repo, 'main', **kwargs)
|
||||
assert not api.requests
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body', [{}, {'commit': {'sha': 'oops'}}, {'commit': {'sha': f'{120:040x}'}, 'name': 'different'}])
|
||||
def test_invalid_branch_response(history, repo, monkeypatch, body):
|
||||
monkeypatch.setattr(history, '_get_json', lambda url: body)
|
||||
with pytest.raises(history.VersionHistoryError, match='GitHub'):
|
||||
history.list_versions(repo, 'main')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('rows', [None, {}, [{}], [dict(commit(120), sha='wrong')], [dict(commit(120), commit={'message': 'x', 'committer': {'date': 'bad'}})], [commit(119)], [commit(120), commit(120)]])
|
||||
def test_invalid_commit_response(history, repo, monkeypatch, rows):
|
||||
fake = GitHub()
|
||||
monkeypatch.setattr(history, '_get_json', lambda url: rows if '/commits?' in url else fake(url))
|
||||
with pytest.raises(history.VersionHistoryError, match='GitHub'):
|
||||
history.list_versions(repo, 'main')
|
||||
|
||||
|
||||
def test_cache_expires_and_returns_independent_results(history, repo, api, monkeypatch):
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
|
||||
first = history.list_versions(repo, 'main')
|
||||
first['commits'][0]['subject'] = 'corrupt'
|
||||
count = len(api.requests)
|
||||
assert history.list_versions(repo, 'main')['commits'][0]['subject'] == 'Change 120'
|
||||
assert len(api.requests) == count
|
||||
api.heads['main'] = 121
|
||||
clock[0] += 61
|
||||
assert history.list_versions(repo, 'main')['head'] == f'{121:040x}'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('code', [403, 429, 404, 500])
|
||||
def test_http_errors_are_actionable(history, repo, monkeypatch, code):
|
||||
def fail(*args, **kwargs):
|
||||
raise HTTPError('https://api.github.com/', code, 'failure', {'Retry-After': '60'}, io.BytesIO(b'{}'))
|
||||
monkeypatch.setattr(history, '_open_url', fail)
|
||||
with pytest.raises(history.VersionHistoryError, match='rate limit|retry|not found'):
|
||||
history.list_versions(repo, 'main')
|
||||
|
||||
|
||||
def test_network_failure_is_retryable(history, repo, monkeypatch):
|
||||
def fail(*args, **kwargs):
|
||||
raise URLError('offline')
|
||||
monkeypatch.setattr(history, '_open_url', fail)
|
||||
with pytest.raises(history.VersionHistoryError, match='retry'):
|
||||
history.list_versions(repo, 'main')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('content', [b'{broken', b'x' * (4 * 1024 * 1024 + 1)], ids=['invalid-json', 'oversized-json'])
|
||||
def test_http_invalid_or_oversized_json_is_bounded(history, repo, monkeypatch, content):
|
||||
monkeypatch.setattr(history, '_open_url', lambda *a, **kw: io.BytesIO(content))
|
||||
with pytest.raises(history.VersionHistoryError, match='GitHub'):
|
||||
history.list_versions(repo, 'main')
|
||||
|
||||
|
||||
def test_truncated_http_response_is_retryable(history, repo, monkeypatch):
|
||||
class Truncated(io.BytesIO):
|
||||
def read(self, *args):
|
||||
raise IncompleteRead(b'partial', 100)
|
||||
monkeypatch.setattr(history, '_open_url', lambda *a, **kw: Truncated())
|
||||
with pytest.raises(history.VersionHistoryError, match='retry'):
|
||||
history.list_versions(repo, 'main')
|
||||
|
||||
|
||||
def test_full_block_has_more_is_exact(history, repo, api, monkeypatch):
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
|
||||
api.heads['main'] = 100
|
||||
page = history.list_versions(repo, 'main', page=4, head=f'{100:040x}')
|
||||
assert len(page['commits']) == 25 and page['hasMore'] is False
|
||||
api.heads['main'] = 101
|
||||
clock[0] += 61 # Let browsing discover the new branch head.
|
||||
page = history.list_versions(repo, 'main', page=4, head=f'{101:040x}')
|
||||
assert page['hasMore'] is True
|
||||
|
||||
|
||||
def test_evicted_branch_is_refreshed_after_many_branches(history, repo, api):
|
||||
history.list_versions(repo, 'main')
|
||||
for n in range(65):
|
||||
api.heads[f'branch{n}'] = 1
|
||||
history.list_versions(repo, f'branch{n}')
|
||||
api.heads['main'] = 121
|
||||
assert history.list_versions(repo, 'main')['head'] == f'{121:040x}'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', ['https://evil.test/data', 'http://api.github.com/repos/a/b', 'https://api.github.com.evil.test/data'])
|
||||
def test_redirects_cannot_leave_github_api(history, url):
|
||||
handler = history._GitHubRedirectHandler()
|
||||
with pytest.raises(history.VersionHistoryError, match='redirect'):
|
||||
handler.redirect_request(Request('https://api.github.com/repos/a/b'), None, 301, 'Moved', {}, url)
|
||||
|
||||
|
||||
def test_canonical_github_repository_redirect_is_allowed(history):
|
||||
redirected = history._GitHubRedirectHandler().redirect_request(Request('https://api.github.com/repos/firestar5683/openpilot'), None, 301, 'Moved', {}, 'https://api.github.com/repos/firestar5683/StarPilot')
|
||||
assert redirected.full_url == 'https://api.github.com/repos/firestar5683/StarPilot'
|
||||
|
||||
|
||||
def test_history_has_no_artificial_depth_limit(history, repo, api):
|
||||
api.heads['main'] = 30000
|
||||
result = history.list_versions(repo, 'main', page=1001, head=f'{30000:040x}')
|
||||
assert len(result['commits']) == 25
|
||||
assert result['commits'][0]['sha'] == f'{5000:040x}'
|
||||
assert result['hasMore'] is True
|
||||
assert len(api.requests) == 2
|
||||
|
||||
|
||||
def test_starpilot_versions_come_from_each_exact_sha(history, repo, api, monkeypatch):
|
||||
api.heads['StarPilot'] = 3
|
||||
requests = []
|
||||
def raw(request, **kwargs):
|
||||
requests.append(request.full_url)
|
||||
sha = request.full_url.split('/')[-5]
|
||||
version = '6.7.7' if int(sha, 16) >= 2 else '6.7.6'
|
||||
return io.BytesIO(f'STARPILOT_DISPLAY_VERSION = "{version}"\n'.encode())
|
||||
monkeypatch.setattr(history, '_open_raw_url', raw, raising=False)
|
||||
rows = history.list_versions(repo, 'StarPilot')['commits']
|
||||
assert [r.get('version') for r in rows] == ['6.7.7', '6.7.7', '6.7.6']
|
||||
assert len(requests) == 3
|
||||
assert all(url.startswith('https://raw.githubusercontent.com/firestar5683/openpilot/') for url in requests)
|
||||
assert all(url.endswith('/selfdrive/ui/lib/starpilot_version.py') for url in requests)
|
||||
assert [r['sha'] for r in rows] == [f'{n:040x}' for n in (3, 2, 1)]
|
||||
|
||||
|
||||
def test_other_branches_do_not_fetch_starpilot_versions(history, repo, api, monkeypatch):
|
||||
def raw(*args, **kwargs):
|
||||
pytest.fail('Other branches must not request StarPilot version files')
|
||||
monkeypatch.setattr(history, '_open_raw_url', raw, raising=False)
|
||||
assert len(history.list_versions(repo, 'main')['commits']) == 25
|
||||
|
||||
|
||||
def test_missing_old_version_file_is_explicit_and_cached(history, repo, api, monkeypatch):
|
||||
api.heads['StarPilot'] = 1
|
||||
calls = []
|
||||
def raw(request, **kwargs):
|
||||
calls.append(request.full_url)
|
||||
raise HTTPError(request.full_url, 404, 'Not Found', {}, io.BytesIO())
|
||||
monkeypatch.setattr(history, '_open_raw_url', raw, raising=False)
|
||||
first = history.list_versions(repo, 'StarPilot')
|
||||
assert first['commits'][0]['version'] is None
|
||||
assert history.list_versions(repo, 'StarPilot')['commits'][0]['version'] is None
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body', [
|
||||
b'STARPILOT_DISPLAY_VERSION = "6.7.7-beta"\n',
|
||||
b'STARPILOT_DISPLAY_VERSION = str(6.7)\n',
|
||||
b'STARPILOT_DISPLAY_VERSION = "6.7.7"; raise RuntimeError()\n',
|
||||
b'STARPILOT_DISPLAY_VERSION = "6.7.7"\nSTARPILOT_DISPLAY_VERSION = "6.7.6"\n',
|
||||
b'not python',
|
||||
b'\xff',
|
||||
b'x' * (64 * 1024 + 1),
|
||||
], ids=['non-numeric', 'expression', 'extra-statement', 'ambiguous', 'missing-literal', 'invalid-encoding', 'oversized'])
|
||||
def test_invalid_version_metadata_is_retryable(history, repo, api, monkeypatch, body):
|
||||
api.heads['StarPilot'] = 1
|
||||
monkeypatch.setattr(history, '_open_raw_url', lambda *a, **kw: io.BytesIO(body), raising=False)
|
||||
with pytest.raises(history.VersionHistoryError, match='version.*retry'):
|
||||
history.list_versions(repo, 'StarPilot')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure', [
|
||||
HTTPError('https://raw.githubusercontent.com/', 429, 'Slow down', {}, io.BytesIO()),
|
||||
HTTPError('https://raw.githubusercontent.com/', 500, 'Unavailable', {}, io.BytesIO()),
|
||||
URLError('offline'), IncompleteRead(b'partial', 100),
|
||||
])
|
||||
def test_version_network_failure_is_not_cached_as_missing(history, repo, api, monkeypatch, failure):
|
||||
api.heads['StarPilot'] = 1
|
||||
def fail(*args, **kwargs):
|
||||
raise failure
|
||||
monkeypatch.setattr(history, '_open_raw_url', fail, raising=False)
|
||||
with pytest.raises(history.VersionHistoryError, match='retry'):
|
||||
history.list_versions(repo, 'StarPilot')
|
||||
monkeypatch.setattr(history, '_open_raw_url', lambda *a, **kw: io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n'))
|
||||
assert history.list_versions(repo, 'StarPilot')['commits'][0]['version'] == '6.7.7'
|
||||
|
||||
|
||||
def test_version_cache_outlives_branch_cache_and_expires(history, repo, api, monkeypatch):
|
||||
api.heads['StarPilot'] = 1
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
|
||||
calls = []
|
||||
def raw(*args, **kwargs):
|
||||
calls.append(1)
|
||||
return io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n')
|
||||
monkeypatch.setattr(history, '_open_raw_url', raw, raising=False)
|
||||
assert history.list_versions(repo, 'StarPilot')['commits'][0]['version'] == '6.7.7'
|
||||
clock[0] += 3600
|
||||
history.list_versions(repo, 'StarPilot')
|
||||
assert len(calls) == 1
|
||||
clock[0] += 24 * 3600
|
||||
history.list_versions(repo, 'StarPilot')
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('callers', [1, 2])
|
||||
def test_version_requests_are_parallel_and_bounded(history, repo, api, monkeypatch, callers):
|
||||
import threading
|
||||
import time
|
||||
api.heads['StarPilot'] = 8
|
||||
lock = threading.Lock()
|
||||
counts = {'active': 0, 'peak': 0}
|
||||
def raw(*args, **kwargs):
|
||||
with lock:
|
||||
counts['active'] += 1
|
||||
counts['peak'] = max(counts['peak'], counts['active'])
|
||||
time.sleep(0.03)
|
||||
with lock:
|
||||
counts['active'] -= 1
|
||||
return io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n')
|
||||
monkeypatch.setattr(history, '_open_raw_url', raw, raising=False)
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=callers) as clients:
|
||||
results = list(clients.map(lambda _: history.list_versions(repo, 'StarPilot'), range(callers)))
|
||||
assert all(row['version'] == '6.7.7' for result in results for row in result['commits'])
|
||||
assert 1 < counts['peak'] <= 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', ['https://evil.test/file', 'http://raw.githubusercontent.com/file', 'https://api.github.com/file'])
|
||||
def test_raw_redirects_stay_on_https_raw_github(history, url):
|
||||
with pytest.raises(history.VersionHistoryError, match='redirect'):
|
||||
history._RawGitHubRedirectHandler().redirect_request(Request('https://raw.githubusercontent.com/a/b'), None, 301, 'Moved', {}, url)
|
||||
|
||||
|
||||
def test_failed_version_request_cancels_pending_work_promptly(history, repo, api, monkeypatch):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import threading
|
||||
api.heads['StarPilot'] = 25
|
||||
started = threading.Barrier(4)
|
||||
release = threading.Event()
|
||||
downloads = []
|
||||
class RecordingExecutor(ThreadPoolExecutor):
|
||||
def submit(self, *args, **kwargs):
|
||||
future = super().submit(*args, **kwargs)
|
||||
downloads.append(future)
|
||||
return future
|
||||
monkeypatch.setattr(history, 'ThreadPoolExecutor', RecordingExecutor)
|
||||
def raw(request, **kwargs):
|
||||
if int(request.full_url.split('/')[-5], 16) >= 22:
|
||||
started.wait(timeout=5)
|
||||
if request.full_url.split('/')[-5] == f'{25:040x}':
|
||||
raise URLError('offline')
|
||||
release.wait(timeout=5)
|
||||
return io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n')
|
||||
monkeypatch.setattr(history, '_open_raw_url', raw)
|
||||
with ThreadPoolExecutor(max_workers=1) as caller:
|
||||
result = caller.submit(history.list_versions, repo, 'StarPilot')
|
||||
try:
|
||||
with pytest.raises(history.VersionHistoryError, match='retry'):
|
||||
result.result(timeout=1)
|
||||
finally:
|
||||
release.set()
|
||||
assert sum(future.cancelled() for future in downloads) >= 20
|
||||
for future in downloads:
|
||||
if not future.cancelled():
|
||||
try:
|
||||
future.result(timeout=2)
|
||||
except history.VersionHistoryError:
|
||||
pass
|
||||
|
||||
|
||||
def test_version_cache_is_bounded_and_preserves_recent_entries(history, monkeypatch):
|
||||
calls = []
|
||||
def raw(request, **kwargs):
|
||||
calls.append(request.full_url)
|
||||
return io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n')
|
||||
monkeypatch.setattr(history, '_open_raw_url', raw)
|
||||
monkeypatch.setattr(history, 'MAX_VERSION_CACHE_ENTRIES', 2)
|
||||
base = 'https://api.github.com/repos/firestar5683/openpilot'
|
||||
for n in (1, 2, 1, 3, 1):
|
||||
assert history._display_version(base, f'{n:040x}') == '6.7.7'
|
||||
assert len(calls) == 3
|
||||
history._display_version(base, f'{2:040x}')
|
||||
assert len(calls) == 4
|
||||
|
||||
|
||||
def test_version_cache_does_not_mix_repository_origins(history, monkeypatch):
|
||||
def raw(request, **kwargs):
|
||||
version = '6.7.7' if '/firestar5683/' in request.full_url else '6.7.6'
|
||||
return io.BytesIO(f"STARPILOT_DISPLAY_VERSION = '{version}' # display\n".encode())
|
||||
monkeypatch.setattr(history, '_open_raw_url', raw)
|
||||
assert history._display_version('https://api.github.com/repos/firestar5683/openpilot', f'{1:040x}') == '6.7.7'
|
||||
assert history._display_version('https://api.github.com/repos/other/openpilot', f'{1:040x}') == '6.7.6'
|
||||
|
||||
|
||||
def test_raw_canonical_repository_redirect_is_allowed(history):
|
||||
url = 'https://raw.githubusercontent.com/firestar5683/StarPilot/' + f'{1:040x}' + '/selfdrive/ui/lib/starpilot_version.py'
|
||||
redirected = history._RawGitHubRedirectHandler().redirect_request(Request('https://raw.githubusercontent.com/firestar5683/openpilot/file'), None, 301, 'Moved', {}, url)
|
||||
assert redirected.full_url == url
|
||||
|
||||
|
||||
def test_paging_reuses_head_and_immutable_blocks_for_six_hours(history, repo, api, monkeypatch):
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
|
||||
first = history.list_versions(repo, 'main')
|
||||
assert len(api.requests) == 2
|
||||
history.list_versions(repo, 'main', page=2, head=first['head'])
|
||||
assert len(api.requests) == 2
|
||||
clock[0] += 3600
|
||||
history.list_versions(repo, 'main', page=3, head=first['head'])
|
||||
assert len(api.requests) == 3 # Only the mutable head refreshes.
|
||||
clock[0] += 6 * 3600
|
||||
history.list_versions(repo, 'main', page=3, head=first['head'])
|
||||
assert len(api.requests) == 5 # Head plus expired immutable block.
|
||||
|
||||
|
||||
def test_immutable_ancestry_cache_outlives_browser_head_cache(history, repo, api, monkeypatch):
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
|
||||
base = 'https://api.github.com/repos/firestar5683/openpilot'
|
||||
history._ancestor(base, f'{100:040x}', f'{120:040x}')
|
||||
clock[0] += 3600
|
||||
history._ancestor(base, f'{100:040x}', f'{120:040x}')
|
||||
assert len(api.requests) == 1
|
||||
clock[0] += 6 * 3600
|
||||
history._ancestor(base, f'{100:040x}', f'{120:040x}')
|
||||
assert len(api.requests) == 2
|
||||
|
||||
|
||||
def test_install_rejects_force_push_during_browser_cache_window(history, repo, api):
|
||||
old = history.list_versions(repo, 'main')['head']
|
||||
api.heads['main'] = 80
|
||||
with pytest.raises(history.VersionHistoryError, match='branch'):
|
||||
history.resolve_version(repo, 'main', old)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('headers,expected', [
|
||||
({'Retry-After': '90', 'X-RateLimit-Reset': '3280'}, '90 seconds'),
|
||||
({'X-RateLimit-Reset': '3280'}, '38 minutes'),
|
||||
({'Retry-After': 'bad', 'X-RateLimit-Reset': '3280'}, '38 minutes'),
|
||||
({'X-RateLimit-Reset': '999'}, 'retry later'),
|
||||
])
|
||||
def test_rate_limit_reports_retry_time(history, monkeypatch, headers, expected):
|
||||
monkeypatch.setattr(history.time, 'time', lambda: 1000.0)
|
||||
def fail(*args, **kwargs):
|
||||
raise HTTPError('https://api.github.com/', 403, 'Limited', headers, io.BytesIO())
|
||||
monkeypatch.setattr(history, '_open_url', fail)
|
||||
with pytest.raises(history.VersionHistoryError, match=expected):
|
||||
history._get_json('https://api.github.com/repos/a/b')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('code', [403, 429])
|
||||
def test_rate_limit_short_backoff_is_shared_and_then_retries(history, monkeypatch, code):
|
||||
clock = [1000.0]
|
||||
monkeypatch.setattr(history.time, 'time', lambda: clock[0])
|
||||
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
|
||||
calls = []
|
||||
def raw(*args, **kwargs):
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise HTTPError('https://api.github.com/', code, 'Limited', {'X-RateLimit-Reset': '3280', 'X-RateLimit-Remaining': '0'}, io.BytesIO())
|
||||
return io.BytesIO(b'{"ok": true}')
|
||||
monkeypatch.setattr(history, '_open_url', raw)
|
||||
for path in ('branches/main', 'commits'):
|
||||
with pytest.raises(history.VersionHistoryError, match='38 minutes'):
|
||||
history._get_json('https://api.github.com/repos/a/b/' + path)
|
||||
assert len(calls) == 1
|
||||
clock[0] += 61
|
||||
assert history._get_json('https://api.github.com/repos/a/b/branches/main') == {'ok': True}
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_unrelated_403_does_not_backoff_other_metadata(history, monkeypatch):
|
||||
calls = []
|
||||
def raw(*args, **kwargs):
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise HTTPError('https://api.github.com/', 403, 'Forbidden', {}, io.BytesIO())
|
||||
return io.BytesIO(b'{"ok": true}')
|
||||
monkeypatch.setattr(history, '_open_url', raw)
|
||||
with pytest.raises(history.VersionHistoryError, match='restriction'):
|
||||
history._get_json('https://api.github.com/repos/a/b')
|
||||
assert history._get_json('https://api.github.com/repos/c/d') == {'ok': True}
|
||||
|
||||
|
||||
def test_backoff_keeps_cached_history_available_but_does_not_stale_install_head(history, repo, monkeypatch):
|
||||
fake = GitHub()
|
||||
original_get_json = history._get_json
|
||||
monkeypatch.setattr(history, '_get_json', fake)
|
||||
first = history.list_versions(repo, 'main')
|
||||
# Restore the actual transport, then establish a known quota backoff.
|
||||
monkeypatch.setattr(history, '_get_json', original_get_json)
|
||||
def fail(*args, **kwargs):
|
||||
raise HTTPError('https://api.github.com/', 403, 'Limited', {'X-RateLimit-Remaining': '0'}, io.BytesIO())
|
||||
monkeypatch.setattr(history, '_open_url', fail)
|
||||
with pytest.raises(history.VersionHistoryError, match='retry'):
|
||||
history._get_json('https://api.github.com/repos/a/b')
|
||||
assert history.list_versions(repo, 'main', page=2, head=first['head'])['commits'][0]['sha'] == f'{95:040x}'
|
||||
with pytest.raises(history.VersionHistoryError, match='retry'):
|
||||
history.resolve_version(repo, 'main', 'latest')
|
||||
|
||||
|
||||
|
||||
def offline_reload(history, monkeypatch):
|
||||
spec = importlib.util.spec_from_file_location('history_reloaded_offline', MODULE)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
monkeypatch.setattr(module, 'SNAPSHOT_CACHE_DIR', history.SNAPSHOT_CACHE_DIR, raising=False)
|
||||
def fail(*args, **kwargs):
|
||||
raise URLError('offline')
|
||||
monkeypatch.setattr(module, '_open_url', fail)
|
||||
return module
|
||||
|
||||
|
||||
def test_snapshot_survives_module_reload_and_keeps_original_timestamp(history, repo, api, monkeypatch):
|
||||
first = history.list_versions(repo, 'main')
|
||||
offline = offline_reload(history, monkeypatch)
|
||||
cached = offline.list_versions(repo, 'main')
|
||||
assert cached['cached'] is True
|
||||
assert cached['commits'] == first['commits'] and cached['head'] == first['head']
|
||||
assert 'retry' in cached['cacheReason']
|
||||
original_time = cached['cachedAt']
|
||||
assert offline.list_versions(repo, 'main')['cachedAt'] == original_time
|
||||
|
||||
|
||||
def test_snapshot_pagination_requires_the_exact_requested_head(history, repo, api, monkeypatch):
|
||||
first = history.list_versions(repo, 'main')
|
||||
page = history.list_versions(repo, 'main', page=2, head=first['head'])
|
||||
offline = offline_reload(history, monkeypatch)
|
||||
assert offline.list_versions(repo, 'main', page=2, head=first['head'])['commits'] == page['commits']
|
||||
with pytest.raises(offline.VersionHistoryError, match='retry'):
|
||||
offline.list_versions(repo, 'main', page=2, head=f'{119:040x}')
|
||||
with pytest.raises(offline.VersionHistoryError, match='retry'):
|
||||
offline.list_versions(repo, 'main', page=3, head=first['head'])
|
||||
|
||||
|
||||
def test_snapshot_does_not_cross_repository_origins(history, repo, api, monkeypatch):
|
||||
history.list_versions(repo, 'main')
|
||||
subprocess.run(['git', '-C', str(repo), 'remote', 'set-url', 'origin', 'https://github.com/other/openpilot'], check=True)
|
||||
offline = offline_reload(history, monkeypatch)
|
||||
with pytest.raises(offline.VersionHistoryError, match='retry'):
|
||||
offline.list_versions(repo, 'main')
|
||||
|
||||
|
||||
def test_snapshot_never_bypasses_install_freshness(history, repo, api, monkeypatch):
|
||||
first = history.list_versions(repo, 'main')
|
||||
offline = offline_reload(history, monkeypatch)
|
||||
for chosen in ('latest', first['head']):
|
||||
with pytest.raises(offline.VersionHistoryError, match='retry'):
|
||||
offline.resolve_version(repo, 'main', chosen)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure_kind', ['rewrite', '404', 'malformed'])
|
||||
def test_snapshot_does_not_hide_invalid_live_metadata(history, repo, api, monkeypatch, failure_kind):
|
||||
first = history.list_versions(repo, 'main')
|
||||
history.list_versions(repo, 'main', page=2, head=first['head'])
|
||||
live = offline_reload(history, monkeypatch)
|
||||
if failure_kind == 'rewrite':
|
||||
fake = GitHub()
|
||||
fake.heads['main'] = 80
|
||||
monkeypatch.setattr(live, '_get_json', fake)
|
||||
elif failure_kind == '404':
|
||||
def missing(*args, **kwargs):
|
||||
raise HTTPError('https://api.github.com/', 404, 'Missing', {}, io.BytesIO())
|
||||
monkeypatch.setattr(live, '_open_url', missing)
|
||||
else:
|
||||
monkeypatch.setattr(live, '_get_json', lambda url: {})
|
||||
with pytest.raises(live.VersionHistoryError):
|
||||
live.list_versions(repo, 'main', page=2, head=first['head'])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('damage', ['corrupt', 'oversized', 'origin', 'branch', 'page', 'requested_head', 'row_sha', 'row_date',
|
||||
'row_subject', 'row_version', 'head_mismatch', 'duplicate', 'old', 'future', 'naive_time'])
|
||||
def test_invalid_snapshot_is_ignored(history, repo, api, monkeypatch, damage):
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
history.list_versions(repo, 'main')
|
||||
path = next(history.SNAPSHOT_CACHE_DIR.glob('*.json'))
|
||||
data = json.loads(path.read_text())
|
||||
if damage == 'corrupt':
|
||||
path.write_text('{broken')
|
||||
elif damage == 'oversized':
|
||||
path.write_bytes(b'x' * (128 * 1024 + 1))
|
||||
else:
|
||||
if damage == 'origin':
|
||||
data['repository'] = 'https://api.github.com/repos/other/openpilot'
|
||||
elif damage == 'branch':
|
||||
data['branch'] = 'other'
|
||||
elif damage == 'page':
|
||||
data['result']['page'] = 2
|
||||
elif damage == 'requested_head':
|
||||
data['requestedHead'] = f'{119:040x}'
|
||||
elif damage == 'row_sha':
|
||||
data['result']['commits'][0]['sha'] = 'HEAD'
|
||||
elif damage == 'row_date':
|
||||
data['result']['commits'][0]['date'] = 'yesterday'
|
||||
elif damage == 'row_subject':
|
||||
data['result']['commits'][0]['subject'] = []
|
||||
elif damage == 'row_version':
|
||||
data['result']['commits'][0]['version'] = '<script>'
|
||||
elif damage == 'head_mismatch':
|
||||
data['result']['head'] = f'{119:040x}'
|
||||
elif damage == 'duplicate':
|
||||
data['result']['commits'][1] = data['result']['commits'][0]
|
||||
elif damage == 'old':
|
||||
data['cachedAt'] = (datetime.now(timezone.utc) - timedelta(days=31)).isoformat()
|
||||
elif damage == 'future':
|
||||
data['cachedAt'] = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()
|
||||
elif damage == 'naive_time':
|
||||
data['cachedAt'] = '2026-09-11T12:00:00'
|
||||
path.write_text(json.dumps(data))
|
||||
offline = offline_reload(history, monkeypatch)
|
||||
with pytest.raises(offline.VersionHistoryError, match='retry'):
|
||||
offline.list_versions(repo, 'main')
|
||||
|
||||
|
||||
def test_snapshot_storage_failure_does_not_fail_online_browsing(history, repo, api):
|
||||
history.SNAPSHOT_CACHE_DIR.write_text('not a directory')
|
||||
assert len(history.list_versions(repo, 'main')['commits']) == 25
|
||||
|
||||
|
||||
def test_snapshot_preload_timestamp_is_truthful_and_permissions_private(history, repo, api):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
first = history.list_versions(repo, 'main')
|
||||
saved_at = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
|
||||
base = 'https://api.github.com/repos/firestar5683/openpilot'
|
||||
assert history._save_history_snapshot(base, 'main', 1, None, first, saved_at=saved_at)
|
||||
cached = history._load_history_snapshot(base, 'main', 1, None)
|
||||
assert cached['cachedAt'] == saved_at
|
||||
path = next(history.SNAPSHOT_CACHE_DIR.glob('*.json'))
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
assert history.SNAPSHOT_CACHE_DIR.stat().st_mode & 0o777 == 0o700
|
||||
assert not list(history.SNAPSHOT_CACHE_DIR.glob('*.tmp'))
|
||||
|
||||
|
||||
def test_snapshot_prunes_file_count_and_total_bytes(history, repo, api, monkeypatch):
|
||||
monkeypatch.setattr(history, 'MAX_SNAPSHOT_FILES', 3)
|
||||
for n in range(5):
|
||||
api.heads[f'branch{n}'] = 120
|
||||
history.list_versions(repo, f'branch{n}')
|
||||
files = list(history.SNAPSHOT_CACHE_DIR.glob('*.json'))
|
||||
assert len(files) == 3
|
||||
one_file_size = max(path.stat().st_size for path in files)
|
||||
monkeypatch.setattr(history, 'MAX_SNAPSHOT_TOTAL_BYTES', one_file_size + 20)
|
||||
api.heads['branch5'] = 120
|
||||
history.list_versions(repo, 'branch5')
|
||||
assert sum(path.stat().st_size for path in history.SNAPSHOT_CACHE_DIR.glob('*.json')) <= one_file_size + 20
|
||||
|
||||
|
||||
def test_snapshot_prunes_old_files_on_successful_write(history, repo, api, monkeypatch):
|
||||
history.list_versions(repo, 'main')
|
||||
now = history.time.time()
|
||||
monkeypatch.setattr(history.time, 'time', lambda: now + 31 * 86400)
|
||||
api.heads['other'] = 120
|
||||
history.list_versions(repo, 'other')
|
||||
assert len(list(history.SNAPSHOT_CACHE_DIR.glob('*.json'))) == 1
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize('code', [403, 429])
|
||||
def test_quota_failure_uses_labelled_snapshot(history, repo, api, monkeypatch, code):
|
||||
first = history.list_versions(repo, 'main')
|
||||
offline = offline_reload(history, monkeypatch)
|
||||
def limited(*args, **kwargs):
|
||||
raise HTTPError('https://api.github.com/', code, 'Limited', {'Retry-After': '90'}, io.BytesIO())
|
||||
monkeypatch.setattr(offline, '_open_url', limited)
|
||||
result = offline.list_versions(repo, 'main')
|
||||
assert result['cached'] is True and result['commits'] == first['commits']
|
||||
assert '90 seconds' in result['cacheReason']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure_kind', ['offline', 'invalid'])
|
||||
def test_starpilot_snapshot_fallback_only_for_version_transport_failure(history, repo, api, monkeypatch, failure_kind):
|
||||
api.heads['StarPilot'] = 3
|
||||
monkeypatch.setattr(history, '_open_raw_url', lambda *a, **kw: io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n'))
|
||||
first = history.list_versions(repo, 'StarPilot')
|
||||
offline = offline_reload(history, monkeypatch)
|
||||
monkeypatch.setattr(offline, '_get_json', api)
|
||||
def raw(*args, **kwargs):
|
||||
if failure_kind == 'offline':
|
||||
raise URLError('offline')
|
||||
return io.BytesIO(b'not a version literal')
|
||||
monkeypatch.setattr(offline, '_open_raw_url', raw)
|
||||
if failure_kind == 'offline':
|
||||
result = offline.list_versions(repo, 'StarPilot')
|
||||
assert result['cached'] is True and result['commits'] == first['commits']
|
||||
else:
|
||||
with pytest.raises(offline.VersionHistoryError, match='invalid version'):
|
||||
offline.list_versions(repo, 'StarPilot')
|
||||
|
||||
|
||||
def test_atomic_snapshot_write_failure_leaves_no_partial_file(history, repo, api, monkeypatch):
|
||||
def fail(*args, **kwargs):
|
||||
raise OSError('disk full')
|
||||
monkeypatch.setattr(history.os, 'replace', fail)
|
||||
result = history.list_versions(repo, 'main')
|
||||
assert len(result['commits']) == 25 and 'cached' not in result
|
||||
assert not list(history.SNAPSHOT_CACHE_DIR.iterdir())
|
||||
|
||||
|
||||
def test_unreadable_snapshot_is_ignored(history, repo, api, monkeypatch):
|
||||
history.list_versions(repo, 'main')
|
||||
offline = offline_reload(history, monkeypatch)
|
||||
original_open = Path.open
|
||||
def denied(path, *args, **kwargs):
|
||||
if path.parent == history.SNAPSHOT_CACHE_DIR:
|
||||
raise PermissionError('cache unavailable')
|
||||
return original_open(path, *args, **kwargs)
|
||||
monkeypatch.setattr(Path, 'open', denied)
|
||||
with pytest.raises(offline.HistoryUnavailable):
|
||||
offline.list_versions(repo, 'main')
|
||||
|
||||
|
||||
def test_cached_response_cannot_be_resaved_with_a_new_timestamp(history, repo, api, monkeypatch):
|
||||
history.list_versions(repo, 'main')
|
||||
offline = offline_reload(history, monkeypatch)
|
||||
cached = offline.list_versions(repo, 'main')
|
||||
assert not offline._save_history_snapshot('https://api.github.com/repos/firestar5683/openpilot', 'main', 1, None, cached)
|
||||
assert offline.list_versions(repo, 'main')['cachedAt'] == cached['cachedAt']
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize('transport', ['api', 'raw'])
|
||||
@pytest.mark.parametrize('code,headers,fallback', [
|
||||
(403, {}, False),
|
||||
(403, {'Retry-After': 'invalid'}, False),
|
||||
(403, {'Retry-After': '90'}, True),
|
||||
(403, {'X-RateLimit-Remaining': '0'}, True),
|
||||
(429, {}, True),
|
||||
(503, {}, True),
|
||||
])
|
||||
def test_http_failure_snapshot_fallback_boundary(history, repo, api, monkeypatch, transport, code, headers, fallback):
|
||||
branch = 'main' if transport == 'api' else 'StarPilot'
|
||||
api.heads['StarPilot'] = 3
|
||||
monkeypatch.setattr(history, '_open_raw_url', lambda *a, **kw: io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n'))
|
||||
first = history.list_versions(repo, branch)
|
||||
offline = offline_reload(history, monkeypatch)
|
||||
def fail(*args, **kwargs):
|
||||
raise HTTPError('https://github.com/', code, 'Unavailable', headers, io.BytesIO())
|
||||
if transport == 'api':
|
||||
monkeypatch.setattr(offline, '_open_url', fail)
|
||||
else:
|
||||
monkeypatch.setattr(offline, '_get_json', api)
|
||||
monkeypatch.setattr(offline, '_open_raw_url', fail)
|
||||
if fallback:
|
||||
result = offline.list_versions(repo, branch)
|
||||
assert result['cached'] is True and result['commits'] == first['commits']
|
||||
else:
|
||||
with pytest.raises(offline.VersionHistoryError) as error:
|
||||
offline.list_versions(repo, branch)
|
||||
assert not isinstance(error.value, offline.HistoryUnavailable)
|
||||
|
||||
|
||||
def test_empty_snapshot_preload_timestamp_is_rejected(history, repo, api):
|
||||
first = history.list_versions(repo, 'main')
|
||||
assert not history._save_history_snapshot('https://api.github.com/repos/firestar5683/openpilot', 'main', 1, None, first, saved_at='')
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
const root = process.argv[2] || new URL('../../../../', import.meta.url).pathname
|
||||
const file = root + '/starpilot/system/the_galaxy/assets/mobile/js/components/VersionHistoryPicker.js'
|
||||
const context = vm.createContext({Date, Map, Set})
|
||||
if (fs.existsSync(file)) vm.runInContext(fs.readFileSync(file, 'utf8').replace(/export /g, '') + '\nglobalThis.picker = VersionHistoryPicker; globalThis.group = groupVersionHistory; globalThis.title = versionTitle; globalThis.releases = releaseVersions;', context)
|
||||
const row = (sha, date, version = '6.7.7') => ({sha: sha.repeat(40), date, version, subject: 'Correct launch behavior'})
|
||||
test('history groups every loaded local day and merges nonadjacent page entries', () => {
|
||||
assert.equal(typeof context.group, 'function', 'date-grouped history is available')
|
||||
const groups = context.group([row('a', '2026-09-11T12:00:00'), row('b', '2026-09-10T13:00:00'), row('c', '2026-09-11T09:00:00'), row('d', '2024-01-02T10:00:00')])
|
||||
assert.deepEqual(Array.from(groups, g => g.key), ['2026-09-11', '2026-09-10', '2024-01-02'])
|
||||
assert.deepEqual(Array.from(groups[0].commits, c => c.sha), ['a'.repeat(40), 'c'.repeat(40)])
|
||||
})
|
||||
test('StarPilot uses version numbers with time to distinguish builds and never a hash headline', () => {
|
||||
assert.equal(typeof context.title, 'function', 'friendly version labels are available')
|
||||
const a = context.title(row('a', '2026-09-11T12:00:00'), true)
|
||||
const b = context.title(row('b', '2026-09-11T13:00:00'), true)
|
||||
assert.match(a, /6\.7\.7/)
|
||||
assert.notEqual(a, b)
|
||||
assert.ok(!a.includes('aaaaaaaaaa'))
|
||||
assert.match(context.title(row('c', '2026-09-11T12:00:00', ''), true), /Unnumbered version/)
|
||||
assert.match(context.title(row('a', '2026-09-11T12:00:00'), false), /Correct launch behavior/)
|
||||
})
|
||||
test('bad dates remain selectable in an unknown-date day without hiding history', () => {
|
||||
assert.equal(typeof context.group, 'function')
|
||||
const groups = context.group([row('a', 'not a date'), row('b', '2026-09-10T12:00:00')])
|
||||
assert.equal(groups.length, 2)
|
||||
assert.equal(groups[1].label, 'Unknown date')
|
||||
assert.equal(groups[1].commits[0].sha, 'a'.repeat(40))
|
||||
})
|
||||
test('newly appended days stay collapsed while the current day keeps its state', () => {
|
||||
assert.ok(context.picker, 'history picker is available')
|
||||
const instance = {...context.picker.data(), groups: [{key:'2026-09-11'}, {key:'2026-09-10'}]}
|
||||
context.picker.methods.syncDays.call(instance)
|
||||
assert.deepEqual({...instance.expanded}, {'2026-09-11':true, '2026-09-10':false})
|
||||
instance.expanded['2026-09-10'] = true
|
||||
instance.groups.push({key:'2026-09-09'})
|
||||
context.picker.methods.syncDays.call(instance)
|
||||
assert.equal(instance.expanded['2026-09-10'], true)
|
||||
assert.equal(instance.expanded['2026-09-09'], false)
|
||||
})
|
||||
test('selection emits only an available exact SHA and disabled selection emits nothing', () => {
|
||||
assert.ok(context.picker)
|
||||
const calls = []
|
||||
const instance = {commits: [row('a', '2026-09-11')], disabled:false, close:()=>{}, $emit:(...args)=>calls.push(args)}
|
||||
context.picker.methods.select.call(instance, 'b'.repeat(40))
|
||||
assert.equal(calls.length, 0)
|
||||
context.picker.methods.select.call(instance, 'a'.repeat(40))
|
||||
assert.equal(calls[0][0], 'change')
|
||||
assert.equal(calls[0][1].target.value, 'a'.repeat(40))
|
||||
instance.disabled = true
|
||||
context.picker.methods.select.call(instance, 'a'.repeat(40))
|
||||
assert.equal(calls.length, 1)
|
||||
})
|
||||
|
||||
test('release numbers appear once across pages, retaining the first newest build and numeric ordering', () => {
|
||||
const rows=[row('a','2026-09-11','6.7.7'),row('b','2026-09-10','6.7.7'),row('c','2026-09-09','6.7.6'),row('d','2026-09-08','6.7.7')]
|
||||
assert.deepEqual(Array.from(context.releases(rows),r=>r.sha),['a'.repeat(40),'c'.repeat(40)])
|
||||
assert.deepEqual(Array.from(context.releases([row('a','2026-09-11','6.9.0'),row('b','2026-09-10','6.10.0')]),r=>r.version),['6.10.0','6.9.0'])
|
||||
assert.equal(context.releases([row('a','2026-09-11',null),row('b','2026-09-10',null)]).length,1)
|
||||
assert.equal(context.group(rows).reduce((n,g)=>n+g.commits.length,0),4)
|
||||
})
|
||||
|
||||
test('release picker cannot emit a hidden duplicate build', () => {
|
||||
const calls=[]
|
||||
const instance={releaseBranch:true,commits:[row('a','2026-09-11'),row('b','2026-09-10')],disabled:false,close:()=>{},$emit:(...args)=>calls.push(args)}
|
||||
context.picker.methods.select.call(instance,'b'.repeat(40));assert.equal(calls.length,0)
|
||||
context.picker.methods.select.call(instance,'a'.repeat(40));assert.equal(calls[0][1].target.value,'a'.repeat(40))
|
||||
})
|
||||
@@ -0,0 +1,363 @@
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
SPEC=importlib.util.spec_from_file_location('version_install',Path(__file__).resolve().parents[1]/'version_install.py')
|
||||
if SPEC.origin and Path(SPEC.origin).exists():
|
||||
module=importlib.util.module_from_spec(SPEC);SPEC.loader.exec_module(module)
|
||||
else: module=None
|
||||
|
||||
def git(repo,*args):
|
||||
return subprocess.check_output(['git','-C',str(repo),*args],stderr=subprocess.STDOUT).decode().strip()
|
||||
|
||||
@pytest.fixture
|
||||
def fixture(tmp_path):
|
||||
repo=tmp_path/'repo';repo.mkdir();git(repo,'init','-b','Dom');git(repo,'config','user.email','test@example.invalid');git(repo,'config','user.name','Test')
|
||||
files={'launch_env.sh':'export AGNOS_VERSION="19.6.20"\n','launch_chffrplus.sh':'#!/bin/sh\n','starpilot/common/starpilot_variables.py':'automatic_updates = AutomaticUpdates\n','system/updated/updated.py':'automatic_updates_enabled\n','common/params_keys.h':'AutomaticUpdates\n','system/hardware/tici/agnos.json':'[]\n','feature.txt':'old\n'}
|
||||
for name,value in files.items():
|
||||
p=repo/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_text(value)
|
||||
git(repo,'add','.');git(repo,'commit','-m','first');old=git(repo,'rev-parse','HEAD')
|
||||
(repo/'feature.txt').write_text('new\n');git(repo,'commit','-am','second');new=git(repo,'rev-parse','HEAD')
|
||||
(repo/'feature.txt').write_text('local changes\n');(repo/'local.txt').write_text('untracked settings tool\n')
|
||||
data=tmp_path/'data';params=data/'params/d';params.mkdir(parents=True)
|
||||
(params/'IsOnroad').write_text('0');(params/'IsOffroad').write_text('1');(params/'AutomaticUpdates').write_text('1');(params/'ExampleSetting').write_text('preserve')
|
||||
staging=data/'safe_staging/finalized';staging.mkdir(parents=True);(staging/'.overlay_consistent').write_text('')
|
||||
return repo,data,old,new
|
||||
|
||||
def run(f,check=lambda:None):
|
||||
assert module is not None,'Exact-revision installer is not implemented'
|
||||
repo,data,old,new=f
|
||||
return module.install(repo,{'branch':'Dom','commit':old,'head':new,'pinned':True},data_root=data,check_parked=check,progress=lambda *a:None,require_device_binaries=False)
|
||||
|
||||
def test_installs_exact_revision_and_preserves_local_recovery(fixture):
|
||||
result=run(fixture);repo,data,old,new=fixture
|
||||
assert git(repo,'rev-parse','HEAD')==old
|
||||
assert (repo/'feature.txt').read_text()=='old\n'
|
||||
assert (data/'params/d/ExampleSetting').read_text()=='preserve'
|
||||
assert (data/'params/d/AutomaticUpdates').read_text()=='0'
|
||||
assert not (data/'safe_staging/finalized/.overlay_consistent').exists()
|
||||
backup=Path(result['backup'])
|
||||
assert (backup/'working.patch').stat().st_size>0
|
||||
assert (backup/'recover.py').is_file()
|
||||
assert (backup/'params/ExampleSetting').read_text()=='preserve'
|
||||
assert module.read_pin(repo,data)['commit']==old
|
||||
module.restore(backup,check_parked=lambda:None,restore_data=False)
|
||||
assert git(repo,'rev-parse','HEAD')==new
|
||||
assert (repo/'feature.txt').read_text()=='local changes\n'
|
||||
assert (repo/'local.txt').read_text()=='untracked settings tool\n'
|
||||
|
||||
def test_rejects_target_with_incompatible_agnos_before_checkout(fixture):
|
||||
repo,data,old,new=fixture
|
||||
(repo/'launch_env.sh').write_text('export AGNOS_VERSION="20.0"\n');git(repo,'add','launch_env.sh');git(repo,'commit','-m','new OS')
|
||||
current=git(repo,'rev-parse','HEAD')
|
||||
assert module is not None,'Exact-revision installer is not implemented'
|
||||
with pytest.raises(module.InstallError,match='AGNOS'):
|
||||
run((repo,data,old,current))
|
||||
assert git(repo,'rev-parse','HEAD')==current
|
||||
assert (data/'params/d/AutomaticUpdates').read_text()=='1'
|
||||
|
||||
def test_onroad_guard_prevents_changes(fixture):
|
||||
repo,data,old,new=fixture
|
||||
def reject():raise RuntimeError('park first')
|
||||
with pytest.raises(RuntimeError,match='park first'):run(fixture,check=reject)
|
||||
assert git(repo,'rev-parse','HEAD')==new
|
||||
assert (data/'params/d/AutomaticUpdates').read_text()=='1'
|
||||
|
||||
def test_fetch_sha_mismatch_cannot_install_a_different_commit(fixture):
|
||||
assert module is not None,'Exact-revision installer is not implemented'
|
||||
with pytest.raises(module.InstallError):module.validate_target({'branch':'Dom','commit':'HEAD','pinned':True})
|
||||
|
||||
def test_failed_checkout_keeps_recovery_and_restores_previous_source(fixture,monkeypatch):
|
||||
assert module is not None,'Exact-revision installer is not implemented'
|
||||
repo,data,old,new=fixture
|
||||
original=module.git
|
||||
def fail(repo,*args,**kwargs):
|
||||
if args and args[0]=='checkout' and args[-1]==old:raise module.InstallError('injected checkout failure')
|
||||
return original(repo,*args,**kwargs)
|
||||
monkeypatch.setattr(module,'git',fail)
|
||||
with pytest.raises(module.InstallError,match='injected'):run(fixture)
|
||||
assert git(repo,'rev-parse','HEAD')==new
|
||||
assert (repo/'feature.txt').read_text()=='local changes\n'
|
||||
assert (data/'params/d/ExampleSetting').read_text()=='preserve'
|
||||
|
||||
|
||||
def test_atomic_write_preserves_mode_and_does_not_touch_another_temporary_file(tmp_path):
|
||||
path=tmp_path/'setting';path.write_bytes(b'old');path.chmod(0o640)
|
||||
stale=tmp_path/'setting.version-tmp';stale.write_bytes(b'other writer')
|
||||
module.atomic_write(path,b'new')
|
||||
assert path.read_bytes()==b'new' and stat.S_IMODE(path.stat().st_mode)==0o640
|
||||
assert stale.read_bytes()==b'other writer'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('content',['[]','null','1','"text"','{broken'])
|
||||
def test_malformed_pin_is_ignored(fixture,content):
|
||||
repo,data,*_=fixture
|
||||
path=data/'starpilot/version_selection.json';path.parent.mkdir(parents=True,exist_ok=True);path.write_text(content)
|
||||
assert module.read_pin(repo,data) is None
|
||||
|
||||
|
||||
def test_untracked_symlink_rejected_before_source_or_settings_changes(fixture):
|
||||
repo,data,old,new=fixture
|
||||
(repo/'unsupported-link').symlink_to('feature.txt')
|
||||
with pytest.raises(module.InstallError,match='symlink|regular file'):run(fixture)
|
||||
assert git(repo,'rev-parse','HEAD')==new and (repo/'feature.txt').read_text()=='local changes\n'
|
||||
assert (data/'params/d/AutomaticUpdates').read_text()=='1'
|
||||
assert (data/'safe_staging/finalized/.overlay_consistent').exists()
|
||||
|
||||
|
||||
def test_new_branch_has_explicit_origin_upstream(fixture):
|
||||
repo,data,old,new=fixture
|
||||
git(repo,'remote','add','origin','https://github.com/example/project.git')
|
||||
module.install(repo,{'branch':'feature/old','commit':old,'head':new,'pinned':True},data_root=data,check_parked=lambda:None,progress=lambda *a:None,require_device_binaries=False)
|
||||
assert git(repo,'config','branch.feature/old.remote')=='origin'
|
||||
assert git(repo,'config','branch.feature/old.merge')=='refs/heads/feature/old'
|
||||
|
||||
|
||||
def test_database_backup_contains_wal_and_recovery_keeps_newer_settings_and_stats(fixture):
|
||||
repo,data,old,new=fixture
|
||||
db=data/'starpilot/model_stats.sqlite';db.parent.mkdir(parents=True,exist_ok=True)
|
||||
with sqlite3.connect(db) as connection:
|
||||
connection.execute('pragma journal_mode=WAL');connection.execute('create table events (id integer, note text)')
|
||||
connection.execute('insert into events values (1, ?)',('before',));connection.commit()
|
||||
result=run(fixture)
|
||||
backup=Path(result['backup'])
|
||||
with sqlite3.connect(backup/'model_stats.sqlite') as saved:
|
||||
assert saved.execute('select * from events').fetchall()==[(1,'before')]
|
||||
assert saved.execute('pragma integrity_check').fetchone()==('ok',)
|
||||
connection.execute('insert into events values (2, ?)',('after',));connection.commit()
|
||||
(data/'params/d/ExampleSetting').write_text('newer setting')
|
||||
module.restore(backup,check_parked=lambda:None)
|
||||
assert connection.execute('select * from events').fetchall()==[(1,'before'),(2,'after')]
|
||||
assert (data/'params/d/ExampleSetting').read_text()=='newer setting'
|
||||
assert (backup/'params/ExampleSetting').read_text()=='preserve'
|
||||
|
||||
|
||||
def test_device_preflight_checks_actual_installed_os_before_mutation(fixture,tmp_path,monkeypatch):
|
||||
repo,data,old,new=fixture
|
||||
version=tmp_path/'VERSION';version.write_text('18.0\n')
|
||||
monkeypatch.setattr(module,'OS_VERSION_FILE',version)
|
||||
with pytest.raises(module.InstallError,match='running|installed'):
|
||||
module.install(repo,{'branch':'Dom','commit':old,'head':new,'pinned':True},data_root=data,check_parked=lambda:None,progress=lambda *a:None,require_device_binaries=True)
|
||||
assert git(repo,'rev-parse','HEAD')==new and (data/'params/d/AutomaticUpdates').read_text()=='1'
|
||||
|
||||
|
||||
def test_active_tesla_setting_rejects_target_without_wake_support(fixture):
|
||||
repo,data,old,new=fixture
|
||||
(data/'params/d/TeslaWakeOnCAN').write_text('1')
|
||||
with pytest.raises(module.InstallError,match='TeslaWakeOnCAN'):run(fixture)
|
||||
assert git(repo,'rev-parse','HEAD')==new and (data/'params/d/TeslaWakeOnCAN').read_text()=='1'
|
||||
assert (data/'params/d/AutomaticUpdates').read_text()=='1'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('setting',['RemoteStartBootsComma','HKGRemoteStartBootsComma','IgnoreIgnitionLine'])
|
||||
def test_active_panda_variant_cannot_be_silently_ignored(fixture,setting):
|
||||
repo,data,old,new=fixture
|
||||
(data/'params/d'/setting).write_text('1')
|
||||
with pytest.raises(module.InstallError,match=setting):run(fixture)
|
||||
assert git(repo,'rev-parse','HEAD')==new and (data/'params/d'/setting).read_text()=='1'
|
||||
|
||||
|
||||
def test_dirty_submodule_is_rejected_without_losing_edits(fixture,tmp_path):
|
||||
repo,data,old,new=fixture
|
||||
child=tmp_path/'child';child.mkdir();git(child,'init','-b','main');git(child,'config','user.name','Test');git(child,'config','user.email','test@example.invalid')
|
||||
(child/'nested.txt').write_text('original');git(child,'add','.');git(child,'commit','-m','child')
|
||||
git(repo,'-c','protocol.file.allow=always','submodule','add',str(child),'vendor/child')
|
||||
git(repo,'commit','-m','submodule')
|
||||
current=git(repo,'rev-parse','HEAD');(repo/'vendor/child/nested.txt').write_text('valuable local edit')
|
||||
with pytest.raises(module.InstallError,match='submodule'):run((repo,data,old,current))
|
||||
assert git(repo,'rev-parse','HEAD')==current
|
||||
assert (repo/'vendor/child/nested.txt').read_text()=='valuable local edit'
|
||||
assert (data/'params/d/AutomaticUpdates').read_text()=='1'
|
||||
|
||||
|
||||
def test_backup_failure_does_not_disable_updates_or_change_source(fixture,monkeypatch):
|
||||
repo,data,old,new=fixture
|
||||
def fail(*a,**kw):raise OSError('backup full')
|
||||
monkeypatch.setattr(module.shutil,'copytree',fail)
|
||||
with pytest.raises(OSError,match='backup full'):run(fixture)
|
||||
assert git(repo,'rev-parse','HEAD')==new and (repo/'feature.txt').read_text()=='local changes\n'
|
||||
assert (data/'params/d/AutomaticUpdates').read_text()=='1'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('lock',['index.lock','shallow.lock'])
|
||||
def test_repository_idle_check_rejects_locks_without_deleting_them(fixture,lock):
|
||||
repo,data,*_=fixture
|
||||
path=repo/'.git'/lock;path.write_text('busy')
|
||||
with pytest.raises(module.InstallError,match='lock|busy'):module.check_repository_idle(repo)
|
||||
assert path.read_text()=='busy'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('argv',[[b'python3',b'-m',b'openpilot.system.updated.updated'],[b'system.updated.updated'],[b'python3',b'/data/openpilot/system/updated/updated.py']])
|
||||
def test_updater_recognizes_prefixed_process_titles(argv):
|
||||
assert module._is_updater(argv) is True
|
||||
|
||||
|
||||
def test_updater_does_not_match_unrelated_process():
|
||||
assert module._is_updater([b'python3',b'/tmp/not-updated.py']) is False
|
||||
|
||||
|
||||
def firmware_target(fixture, *, missing=False):
|
||||
repo,data,old,new=fixture
|
||||
selector=(Path(__file__).resolve().parents[4]/'selfdrive/pandad/panda_firmware.py').read_text()
|
||||
path=repo/'selfdrive/pandad/panda_firmware.py';path.parent.mkdir(parents=True);path.write_text(selector)
|
||||
(repo/'common/params_keys.h').write_text('AutomaticUpdates TeslaWakeOnCAN RemoteStartBootsComma HKGRemoteStartBootsComma IgnoreIgnitionLine')
|
||||
for name in ('panda_tesla_wake.bin.signed','panda_h7_tesla_wake.bin.signed'):
|
||||
path=repo/'panda/board/obj'/name;path.parent.mkdir(parents=True,exist_ok=True)
|
||||
if not missing or 'h7' not in name:path.write_bytes(b'firmware-test-content')
|
||||
git(repo,'add','selfdrive','panda','common/params_keys.h');git(repo,'commit','-m','firmware')
|
||||
target=git(repo,'rev-parse','HEAD')
|
||||
(data/'params/d/TeslaWakeOnCAN').write_text('1')
|
||||
return target
|
||||
|
||||
|
||||
def test_active_firmware_variant_validates_target_images(fixture):
|
||||
repo,data,*_=fixture
|
||||
target=firmware_target(fixture)
|
||||
assert module.preflight(repo,target,False,data)['agnos']=='19.6.20'
|
||||
|
||||
|
||||
def test_missing_selected_h7_image_blocks_even_when_other_image_exists(fixture):
|
||||
repo,data,*_=fixture
|
||||
target=firmware_target(fixture,missing=True)
|
||||
with pytest.raises(module.InstallError,match='panda_h7_tesla_wake'):
|
||||
module.preflight(repo,target,False,data)
|
||||
assert (data/'params/d/TeslaWakeOnCAN').read_bytes()==b'1'
|
||||
|
||||
|
||||
def test_modified_recovery_archive_is_rejected_before_checkout(fixture):
|
||||
import io
|
||||
import tarfile
|
||||
repo,data,*_=fixture
|
||||
result=run(fixture)
|
||||
backup=Path(result['backup'])
|
||||
with tarfile.open(backup/'untracked.tar','w') as archive:
|
||||
member=tarfile.TarInfo('../escaped');member.size=4;archive.addfile(member,io.BytesIO(b'evil'))
|
||||
before=git(repo,'rev-parse','HEAD')
|
||||
with pytest.raises(module.InstallError,match='Unsafe recovery'):
|
||||
module.restore(backup,check_parked=lambda:None)
|
||||
assert git(repo,'rev-parse','HEAD')==before
|
||||
assert not (repo.parent/'escaped').exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('restart',[False,True])
|
||||
def test_updater_only_signals_owned_processes_and_restarts_after_install(monkeypatch,restart):
|
||||
records={100:(1,'1000','S',[b'openpilot.system.updated.updated']),
|
||||
101:(100,'1001','S',[b'git',b'fetch']),
|
||||
102:(1,'1002','T',[b'system.updated.updated']),
|
||||
103:(1,'1003','S',[b'other'])}
|
||||
calls=[]
|
||||
monkeypatch.setattr(module,'_processes',lambda:dict(records))
|
||||
def kill(pid,sig):
|
||||
calls.append((pid,sig))
|
||||
parent,start,state,argv=records[pid]
|
||||
records[pid]=(parent,start,'T' if sig==module.signal.SIGSTOP else 'S',argv)
|
||||
monkeypatch.setattr(module.os,'kill',kill)
|
||||
with module.suspend_updater() as control:
|
||||
assert records[100][2]==records[101][2]=='T'
|
||||
if restart:control.restart_after_install()
|
||||
end=module.signal.SIGKILL if restart else module.signal.SIGCONT
|
||||
assert calls==[(100,module.signal.SIGSTOP),(101,module.signal.SIGSTOP),(101,end),(100,end)]
|
||||
assert not any(pid in (102,103) for pid,_ in calls)
|
||||
assert module._ACTIVE_UPDATER.get() is None
|
||||
|
||||
|
||||
def test_updater_does_not_signal_reused_pid(monkeypatch):
|
||||
records={100:(1,'original','S',[b'system.updated.updated'])}
|
||||
calls=[]
|
||||
monkeypatch.setattr(module,'_processes',lambda:dict(records))
|
||||
def kill(pid,sig):
|
||||
calls.append((pid,sig));records[pid]=(1,'original','T',[b'system.updated.updated'])
|
||||
monkeypatch.setattr(module.os,'kill',kill)
|
||||
with module.suspend_updater() as control:
|
||||
control.restart_after_install()
|
||||
records[100]=(1,'replacement','S',[b'other'])
|
||||
assert calls==[(100,module.signal.SIGSTOP)]
|
||||
|
||||
|
||||
def test_failed_install_recovery_marks_updater_for_restart(fixture,monkeypatch):
|
||||
repo,data,old,new=fixture
|
||||
original=module.git
|
||||
def fail(repo,*args,**kwargs):
|
||||
if args and args[0]=='checkout' and args[-1]==old:raise module.InstallError('checkout failure')
|
||||
return original(repo,*args,**kwargs)
|
||||
monkeypatch.setattr(module,'git',fail)
|
||||
monkeypatch.setattr(module,'_processes',lambda:{})
|
||||
with module.suspend_updater() as control:
|
||||
with pytest.raises(module.InstallError,match='previous source restored'):run(fixture)
|
||||
assert control.restart
|
||||
assert (data/'params/d/AutomaticUpdates').read_bytes()==b'0'
|
||||
|
||||
|
||||
def test_failure_after_update_pause_still_restarts_updater(fixture,monkeypatch):
|
||||
repo,data,*_=fixture
|
||||
monkeypatch.setattr(module,'_processes',lambda:{})
|
||||
def fail(*args):raise OSError('staging failure')
|
||||
monkeypatch.setattr(module,'_clear_staging',fail)
|
||||
with module.suspend_updater() as control:
|
||||
with pytest.raises(OSError,match='staging failure'):run(fixture)
|
||||
assert control.restart
|
||||
assert (data/'params/d/AutomaticUpdates').read_bytes()==b'0'
|
||||
|
||||
|
||||
def test_failed_manual_restore_after_pause_still_restarts_updater(fixture,monkeypatch):
|
||||
repo,data,*_=fixture
|
||||
backup=Path(run(fixture)['backup'])
|
||||
(data/'params/d/AutomaticUpdates').write_bytes(b'1')
|
||||
monkeypatch.setattr(module,'_processes',lambda:{})
|
||||
original=module.git
|
||||
def fail(repo,*args,**kwargs):
|
||||
if args and args[0]=='checkout':raise module.InstallError('restore checkout failure')
|
||||
return original(repo,*args,**kwargs)
|
||||
monkeypatch.setattr(module,'git',fail)
|
||||
with module.suspend_updater() as control:
|
||||
with pytest.raises(module.InstallError,match='restore checkout failure'):
|
||||
module.restore(backup,check_parked=lambda:None)
|
||||
assert control.restart
|
||||
assert (data/'params/d/AutomaticUpdates').read_bytes()==b'0'
|
||||
|
||||
|
||||
def test_preflight_failure_does_not_restart_updater(fixture,monkeypatch):
|
||||
repo,data,*_=fixture
|
||||
(repo/'.git/index.lock').write_bytes(b'busy')
|
||||
monkeypatch.setattr(module,'_processes',lambda:{})
|
||||
with module.suspend_updater() as control:
|
||||
with pytest.raises(module.InstallError,match='busy'):run(fixture)
|
||||
assert not control.restart
|
||||
assert (data/'params/d/AutomaticUpdates').read_bytes()==b'1'
|
||||
|
||||
|
||||
def test_reviewed_legacy_firmware_selector_remains_compatible(fixture):
|
||||
repo,data,*_=fixture
|
||||
firmware_target(fixture)
|
||||
path=repo/'selfdrive/pandad/panda_firmware.py'
|
||||
source=path.read_text()
|
||||
guard=' if tesla_wake and (remote_start or hkg_remote_start):\n raise ValueError("Tesla wake firmware cannot be combined with remote-start firmware")\n'
|
||||
path.write_text(source.replace(guard,''))
|
||||
git(repo,'add',str(path));git(repo,'commit','--allow-empty','-m','legacy firmware selector')
|
||||
assert module.preflight(repo,git(repo,'rev-parse','HEAD'),False,data)['agnos']=='19.6.20'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', ['RemoteStartBootsComma', 'HKGRemoteStartBootsComma', 'RemoteStart', 'HkgRemoteStart'])
|
||||
def test_conflicting_enabled_firmware_flags_are_refused(fixture,key):
|
||||
repo,data,*_=fixture
|
||||
target=firmware_target(fixture)
|
||||
(data/'params/d'/key).write_text('1')
|
||||
with pytest.raises(module.InstallError,match='cannot be combined'):
|
||||
module.preflight(repo,target,False,data)
|
||||
assert (data/'params/d/TeslaWakeOnCAN').read_bytes()==b'1'
|
||||
assert (data/'params/d'/key).read_bytes()==b'1'
|
||||
|
||||
|
||||
def test_unrecognized_firmware_selector_is_refused(fixture):
|
||||
repo,data,*_=fixture
|
||||
firmware_target(fixture)
|
||||
path=repo/'selfdrive/pandad/panda_firmware.py'
|
||||
path.write_text(path.read_text().replace('name_parts.extend(["tesla", "wake"])','name_parts.extend(["unexpected", "wake"])'))
|
||||
git(repo,'add',str(path));git(repo,'commit','-m','unrecognized firmware selector')
|
||||
with pytest.raises(module.InstallError,match='unrecognized firmware selection logic'):
|
||||
module.preflight(repo,git(repo,'rev-parse','HEAD'),False,data)
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Preserve Git's index and working tree independently through local recovery."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location('index_installer', Path(__file__).resolve().parents[1] / 'version_install.py')
|
||||
installer = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(installer)
|
||||
|
||||
|
||||
def git(repo, *args):
|
||||
return subprocess.check_output(['git', '-C', str(repo), *args], stderr=subprocess.STDOUT)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def checkout(monkeypatch):
|
||||
monkeypatch.setenv('GIT_ALLOW_PROTOCOL', 'file')
|
||||
monkeypatch.setenv('GIT_CONFIG_NOSYSTEM', '1')
|
||||
monkeypatch.setenv('GIT_CONFIG_GLOBAL', '/dev/null')
|
||||
with tempfile.TemporaryDirectory(prefix='galaxy-index-recovery-') as directory:
|
||||
root = Path(directory)
|
||||
repo = root / 'repo'
|
||||
repo.mkdir()
|
||||
git(repo, 'init', '-b', 'Dom')
|
||||
git(repo, 'config', 'user.email', 'test@example.invalid')
|
||||
git(repo, 'config', 'user.name', 'Index recovery test')
|
||||
files = {
|
||||
'launch_env.sh': b'export AGNOS_VERSION="19.6.20"\n',
|
||||
'launch_chffrplus.sh': b'#!/bin/sh\n',
|
||||
'common/params_keys.h': b'AutomaticUpdates\n',
|
||||
'starpilot/common/starpilot_variables.py': b'automatic_updates\n',
|
||||
'system/updated/updated.py': b'automatic_updates_enabled\n',
|
||||
'system/hardware/tici/agnos.json': b'[]\n',
|
||||
'tracked.txt': b'one\ntwo\nthree\n',
|
||||
'binary.bin': b'\x00original\xff',
|
||||
'removed.txt': b'keep staged deletion\n',
|
||||
}
|
||||
for name, content in files.items():
|
||||
path = repo / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
(repo / 'tracked-link').symlink_to('tracked.txt')
|
||||
git(repo, 'add', '.')
|
||||
git(repo, 'commit', '-m', 'Historical')
|
||||
old = git(repo, 'rev-parse', 'HEAD').decode().strip()
|
||||
(repo / 'latest.txt').write_bytes(b'latest\n')
|
||||
git(repo, 'add', '.')
|
||||
git(repo, 'commit', '-m', 'Current')
|
||||
latest = git(repo, 'rev-parse', 'HEAD').decode().strip()
|
||||
data = root / 'data'
|
||||
params = data / 'params/d'
|
||||
params.mkdir(parents=True)
|
||||
for key, value in {'AutomaticUpdates': b'1', 'IsOnroad': b'0', 'IsOffroad': b'1', 'KeepSetting': b'yes'}.items():
|
||||
(params / key).write_bytes(value)
|
||||
yield repo, data, old, latest
|
||||
assert not root.exists(), 'Disposable index recovery fixture was not removed'
|
||||
print('CLEANUP VERIFIED: ' + str(root))
|
||||
|
||||
|
||||
def install(checkout):
|
||||
repo, data, old, latest = checkout
|
||||
return Path(installer.install(
|
||||
repo, {'branch': 'Dom', 'commit': old, 'head': latest, 'pinned': True}, data_root=data,
|
||||
check_parked=lambda: installer.require_parked(data), progress=lambda *args: None,
|
||||
require_device_binaries=False)['backup'])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('changes', ['staged_only', 'partially_staged', 'binary_modes_symlink_add_remove'])
|
||||
def test_recovery_round_trips_index_and_worktree_separately(checkout, changes):
|
||||
repo, data, old, latest = checkout
|
||||
original = (repo / 'tracked.txt').read_bytes()
|
||||
if changes == 'staged_only':
|
||||
(repo / 'tracked.txt').write_bytes(b'staged value absent from worktree\n')
|
||||
git(repo, 'add', 'tracked.txt')
|
||||
(repo / 'tracked.txt').write_bytes(original)
|
||||
assert git(repo, 'diff', '--binary', 'HEAD') == b''
|
||||
assert git(repo, 'diff', '--cached', '--binary', 'HEAD')
|
||||
elif changes == 'partially_staged':
|
||||
(repo / 'tracked.txt').write_bytes(b'ONE\ntwo\nthree\n')
|
||||
git(repo, 'add', 'tracked.txt')
|
||||
(repo / 'tracked.txt').write_bytes(b'ONE\ntwo\nTHREE\n')
|
||||
else:
|
||||
(repo / 'binary.bin').write_bytes(b'\x00staged binary\xfe')
|
||||
(repo / 'binary.bin').chmod(0o755)
|
||||
(repo / 'tracked-link').unlink()
|
||||
(repo / 'tracked-link').symlink_to('binary.bin')
|
||||
(repo / 'added.bin').write_bytes(b'\x00new staged file\xff')
|
||||
git(repo, 'add', 'binary.bin', 'tracked-link', 'added.bin')
|
||||
git(repo, 'rm', 'removed.txt')
|
||||
(repo / 'binary.bin').write_bytes(b'\x00unstaged binary\xfd')
|
||||
(repo / 'added.bin').write_bytes(b'\x00new staged plus working edit\xfd')
|
||||
# A staged deletion may coexist with an untracked recreation of that path.
|
||||
(repo / 'removed.txt').write_bytes(b'new untracked replacement\n')
|
||||
(repo / 'tool.sh').write_bytes(b'#!/bin/sh\nexit 0\n')
|
||||
(repo / 'tool.sh').chmod(0o755)
|
||||
index_before = git(repo, 'ls-files', '--stage', '-z')
|
||||
staged_before = git(repo, 'diff', '--cached', '--binary', 'HEAD')
|
||||
working_before = git(repo, 'diff', '--binary')
|
||||
status_before = git(repo, 'status', '--porcelain=v1', '--untracked-files=all')
|
||||
backup = install(checkout)
|
||||
assert git(repo, 'rev-parse', 'HEAD').decode().strip() == old
|
||||
installer.restore(backup, check_parked=lambda: installer.require_parked(data))
|
||||
assert git(repo, 'rev-parse', 'HEAD').decode().strip() == latest
|
||||
assert git(repo, 'ls-files', '--stage', '-z') == index_before
|
||||
assert git(repo, 'diff', '--cached', '--binary', 'HEAD') == staged_before
|
||||
assert git(repo, 'diff', '--binary') == working_before
|
||||
assert git(repo, 'status', '--porcelain=v1', '--untracked-files=all') == status_before
|
||||
assert (data / 'params/d/KeepSetting').read_bytes() == b'yes'
|
||||
if changes == 'binary_modes_symlink_add_remove':
|
||||
assert (repo / 'tracked-link').readlink() == Path('binary.bin')
|
||||
assert (repo / 'removed.txt').read_bytes() == b'new untracked replacement\n'
|
||||
assert (repo / 'tool.sh').stat().st_mode & 0o111 == 0o111
|
||||
|
||||
|
||||
def test_legacy_combined_patch_backup_still_restores_worktree(checkout):
|
||||
repo, data, old, latest = checkout
|
||||
(repo / 'tracked.txt').write_bytes(b'legacy combined local changes\n')
|
||||
combined = git(repo, 'diff', '--binary', 'HEAD')
|
||||
backup = install(checkout)
|
||||
# Before index preservation, working.patch represented HEAD -> worktree.
|
||||
(backup / 'index.patch').unlink(missing_ok=True)
|
||||
(backup / 'working.patch').write_bytes(combined)
|
||||
installer.restore(backup, check_parked=lambda: installer.require_parked(data))
|
||||
assert git(repo, 'rev-parse', 'HEAD').decode().strip() == latest
|
||||
assert git(repo, 'diff', '--binary', 'HEAD') == combined
|
||||
assert git(repo, 'diff', '--cached', 'HEAD') == b''
|
||||
|
||||
|
||||
def repository_snapshot(repo, data):
|
||||
return {
|
||||
'head': git(repo, 'rev-parse', 'HEAD'),
|
||||
'index': git(repo, 'ls-files', '--stage', '-z'),
|
||||
'status': git(repo, 'status', '--porcelain=v1', '--untracked-files=all'),
|
||||
'working': (repo / 'tracked.txt').read_bytes(),
|
||||
'params': {path.name: path.read_bytes() for path in (data / 'params/d').iterdir()},
|
||||
}
|
||||
|
||||
|
||||
def test_conflicted_merge_is_rejected_without_losing_index_stages(checkout):
|
||||
repo, data, old, latest = checkout
|
||||
git(repo, 'checkout', '-b', 'conflicting-side')
|
||||
(repo / 'tracked.txt').write_bytes(b'other branch content\n')
|
||||
git(repo, 'commit', '-am', 'Other side')
|
||||
git(repo, 'checkout', 'Dom')
|
||||
(repo / 'tracked.txt').write_bytes(b'current branch content\n')
|
||||
git(repo, 'commit', '-am', 'Current side')
|
||||
merge = subprocess.run(['git', '-C', str(repo), 'merge', 'conflicting-side'], capture_output=True)
|
||||
assert merge.returncode == 1
|
||||
assert git(repo, 'ls-files', '--unmerged')
|
||||
before = repository_snapshot(repo, data)
|
||||
with pytest.raises(installer.InstallError, match='unmerged|operation in progress'):
|
||||
install(checkout)
|
||||
assert repository_snapshot(repo, data) == before
|
||||
assert not (data / 'starpilot/version-backups').exists()
|
||||
# Also prove an unmerged index is rejected when MERGE_HEAD is absent (for
|
||||
# example, externally constructed conflict stages or a damaged operation).
|
||||
(repo / '.git/MERGE_HEAD').unlink()
|
||||
with pytest.raises(installer.InstallError, match='unmerged'):
|
||||
install(checkout)
|
||||
assert repository_snapshot(repo, data) == before
|
||||
assert not (data / 'starpilot/version-backups').exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('marker', [
|
||||
'MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD',
|
||||
'rebase-merge', 'rebase-apply', 'sequencer', 'BISECT_LOG',
|
||||
])
|
||||
def test_active_git_operation_markers_are_rejected_before_mutation(checkout, marker):
|
||||
repo, data, old, latest = checkout
|
||||
(repo / 'tracked.txt').write_bytes(b'valuable local edits\n')
|
||||
marker_path = Path(git(repo, 'rev-parse', '--git-path', marker).decode().strip())
|
||||
if not marker_path.is_absolute():
|
||||
marker_path = repo / marker_path
|
||||
if marker in ('rebase-merge', 'rebase-apply', 'sequencer'):
|
||||
marker_path.mkdir()
|
||||
sentinel = marker_path / 'rehearsal-sentinel'
|
||||
else:
|
||||
sentinel = marker_path
|
||||
sentinel.write_bytes((latest + '\n').encode())
|
||||
before = repository_snapshot(repo, data)
|
||||
with pytest.raises(installer.InstallError, match='operation in progress'):
|
||||
install(checkout)
|
||||
assert repository_snapshot(repo, data) == before
|
||||
assert sentinel.read_bytes() == (latest + '\n').encode()
|
||||
assert not (data / 'starpilot/version-backups').exists()
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Disposable local Git rehearsal; no device, network, reboot or real signals.
|
||||
|
||||
Run with pytest -c /dev/null --confcutdir=<this directory>. Git operations,
|
||||
resolution, compatibility checks, checkout, backups and restore are real. Public
|
||||
metadata transport is answered from a local bare origin. The installed OS file
|
||||
is synthetic; ELF headers exercise validation only, not ARM execution. Recovery
|
||||
uses the copied module in a fresh interpreter with process discovery disabled.
|
||||
"""
|
||||
import ast
|
||||
import importlib.util
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
MODULE_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_module(name, path=None):
|
||||
spec = importlib.util.spec_from_file_location(name, path or MODULE_DIR / (name + '.py'))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def git(repo, *args):
|
||||
return subprocess.check_output(['git', '-C', str(repo), *args], stderr=subprocess.STDOUT, text=True).strip()
|
||||
|
||||
|
||||
def write(root, name, content):
|
||||
path = root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content if isinstance(content, bytes) else content.encode())
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rehearsal(monkeypatch):
|
||||
# Constrain every Git subprocess, including the copied recovery helper, to
|
||||
# local file transport even if a future fixture accidentally adds a remote.
|
||||
monkeypatch.setenv('GIT_ALLOW_PROTOCOL', 'file')
|
||||
monkeypatch.setenv('GIT_CONFIG_NOSYSTEM', '1')
|
||||
monkeypatch.setenv('GIT_CONFIG_GLOBAL', '/dev/null')
|
||||
with tempfile.TemporaryDirectory(prefix='galaxy-version-rehearsal-') as directory:
|
||||
root = Path(directory)
|
||||
installer = load_module('rehearsal_installer', MODULE_DIR / 'version_install.py')
|
||||
history = load_module('rehearsal_history', MODULE_DIR / 'version_history.py')
|
||||
seed = root / 'seed'
|
||||
seed.mkdir()
|
||||
git(seed, 'init', '-b', 'Dom')
|
||||
git(seed, 'config', 'user.name', 'Local rehearsal')
|
||||
git(seed, 'config', 'user.email', 'rehearsal@example.invalid')
|
||||
files = {
|
||||
'launch_env.sh': 'export AGNOS_VERSION="19.6.20"\n',
|
||||
'launch_chffrplus.sh': '#!/bin/sh\n',
|
||||
'common/params_keys.h': 'AutomaticUpdates TeslaWakeOnCAN\n',
|
||||
'starpilot/common/starpilot_variables.py': 'automatic_updates = AutomaticUpdates\n',
|
||||
'system/updated/updated.py': 'automatic_updates_enabled\n',
|
||||
'system/hardware/tici/agnos.json': '[{"name":"system", "hash":"fixture"}]\n',
|
||||
'feature.txt': 'historical\n',
|
||||
'selfdrive/pandad/panda_firmware.py': inspect.getsource(installer._selected_firmware_name).replace(
|
||||
'def _selected_firmware_name(', 'def get_selected_firmware_name(', 1),
|
||||
'panda/board/obj/panda_tesla_wake.bin.signed': b'synthetic firmware fixture',
|
||||
'panda/board/obj/panda_h7_tesla_wake.bin.signed': b'synthetic firmware fixture',
|
||||
}
|
||||
# Deliberately minimal headers: test the native preflight predicate only.
|
||||
elf = bytearray(64)
|
||||
elf[:6], elf[18:20] = b'\x7fELF\x02\x01', b'\xb7\x00'
|
||||
for name in ('common/params_pyx.so', 'selfdrive/pandad/pandad', 'system/camerad/camerad'):
|
||||
files[name] = bytes(elf)
|
||||
for name, content in files.items():
|
||||
write(seed, name, content)
|
||||
git(seed, 'add', '.')
|
||||
git(seed, 'commit', '-m', 'Historical fixture without Galaxy module')
|
||||
old = git(seed, 'rev-parse', 'HEAD')
|
||||
write(seed, 'feature.txt', 'latest\n')
|
||||
write(seed, 'starpilot/system/the_galaxy/version_install.py', (MODULE_DIR / 'version_install.py').read_bytes())
|
||||
git(seed, 'add', '.')
|
||||
git(seed, 'commit', '-m', 'Latest fixture with Galaxy recovery')
|
||||
latest = git(seed, 'rev-parse', 'HEAD')
|
||||
origin, repo = root / 'origin.git', root / 'checkout'
|
||||
git(root, 'clone', '--bare', str(seed), str(origin))
|
||||
git(root, 'clone', '--depth=1', '--branch', 'Dom', origin.as_uri(), str(repo))
|
||||
assert git(repo, 'rev-parse', '--is-shallow-repository') == 'true'
|
||||
assert subprocess.run(['git', '-C', str(repo), 'cat-file', '-e', old], capture_output=True).returncode != 0
|
||||
write(repo, 'feature.txt', 'valuable local changes\n')
|
||||
write(repo, 'local-tool.txt', 'untracked local tool\n')
|
||||
data = root / 'fake-data'
|
||||
for name, value in {'IsOnroad': '0', 'IsOffroad': '1', 'AutomaticUpdates': '1',
|
||||
'ExampleSetting': 'preserve me', 'TeslaWakeOnCAN': '1'}.items():
|
||||
write(data, 'params/d/' + name, value)
|
||||
write(data, 'safe_staging/finalized/.overlay_consistent', '')
|
||||
version_file = write(root, 'VERSION', '19.6.20\n')
|
||||
monkeypatch.setattr(installer, 'OS_VERSION_FILE', version_file)
|
||||
monkeypatch.setattr(installer, '_processes', lambda: {})
|
||||
def no_signal(*args):
|
||||
raise AssertionError('A local rehearsal must never signal a real process')
|
||||
monkeypatch.setattr(installer.os, 'kill', no_signal)
|
||||
|
||||
# Preserve the actual resolver/branch validation/ancestry checks. Only the
|
||||
# external metadata endpoint is represented by our local origin transport.
|
||||
monkeypatch.setattr(history, '_repository', lambda path: 'local-rehearsal')
|
||||
requests = []
|
||||
def local_metadata(url):
|
||||
requests.append(url)
|
||||
if url == 'local-rehearsal/branches/Dom':
|
||||
return {'name': 'Dom', 'commit': {'sha': git(origin, 'rev-parse', 'refs/heads/Dom')}}
|
||||
prefix = 'local-rehearsal/compare/'
|
||||
assert url.startswith(prefix), url
|
||||
chosen, head = url.removeprefix(prefix).split('...')
|
||||
ancestor = git(origin, 'merge-base', chosen, head)
|
||||
return {'status': 'ahead', 'merge_base_commit': {'sha': ancestor}}
|
||||
monkeypatch.setattr(history, '_get_json', local_metadata)
|
||||
tree = ast.parse((MODULE_DIR / 'the_galaxy.py').read_text())
|
||||
fetch_node = next(node for node in tree.body if isinstance(node, ast.FunctionDef)
|
||||
and node.name == '_build_shallow_fetch_commit_args')
|
||||
namespace = {}
|
||||
exec(compile(ast.Module(body=[fetch_node], type_ignores=[]), str(MODULE_DIR / 'the_galaxy.py'), 'exec'), namespace)
|
||||
(data / 'starpilot').mkdir()
|
||||
connection = sqlite3.connect(data / 'starpilot/model_stats.sqlite')
|
||||
connection.execute('PRAGMA journal_mode=WAL')
|
||||
connection.execute('PRAGMA wal_autocheckpoint=0')
|
||||
connection.execute('CREATE TABLE events (id INTEGER PRIMARY KEY, note TEXT)')
|
||||
connection.execute('INSERT INTO events VALUES (1, "before install")')
|
||||
connection.commit()
|
||||
assert Path(str(data / 'starpilot/model_stats.sqlite') + '-wal').stat().st_size > 0
|
||||
state = SimpleNamespace(root=root, repo=repo, data=data, origin=origin, old=old, latest=latest,
|
||||
installer=installer, history=history, connection=connection,
|
||||
requests=requests, version_file=version_file,
|
||||
fetch_args=namespace['_build_shallow_fetch_commit_args'])
|
||||
try:
|
||||
yield state
|
||||
finally:
|
||||
connection.close()
|
||||
assert not root.exists(), 'Disposable install environment was not removed'
|
||||
print('CLEANUP VERIFIED: ' + str(root))
|
||||
|
||||
|
||||
def fetch_target(state, selection):
|
||||
target = state.history.resolve_version(state.repo, 'Dom', selection)
|
||||
git(state.repo, *state.fetch_args(target['commit']))
|
||||
assert git(state.repo, 'rev-parse', 'FETCH_HEAD^{commit}') == target['commit']
|
||||
return target
|
||||
|
||||
|
||||
def install_target(state, target):
|
||||
progress = []
|
||||
with state.installer.suspend_updater() as control:
|
||||
result = state.installer.install(
|
||||
state.repo, target, data_root=state.data,
|
||||
check_parked=lambda: state.installer.require_parked(state.data),
|
||||
progress=lambda *args: progress.append(args), require_device_binaries=True)
|
||||
assert control.restart
|
||||
assert progress[-1][2] == 100
|
||||
return Path(result['backup'])
|
||||
|
||||
|
||||
def test_real_shallow_install_external_recovery_and_return_latest(rehearsal):
|
||||
state = rehearsal
|
||||
backup = install_target(state, fetch_target(state, state.old))
|
||||
assert git(state.repo, 'rev-parse', 'HEAD') == state.old
|
||||
assert (state.repo / 'feature.txt').read_text() == 'historical\n'
|
||||
assert not (state.repo / 'starpilot/system/the_galaxy/version_install.py').exists()
|
||||
assert state.installer.read_pin(state.repo, state.data)['commit'] == state.old
|
||||
assert (state.data / 'params/d/AutomaticUpdates').read_bytes() == b'0'
|
||||
assert (state.data / 'params/d/ExampleSetting').read_bytes() == b'preserve me'
|
||||
assert (state.data / 'params/d/TeslaWakeOnCAN').read_bytes() == b'1'
|
||||
assert not (state.data / 'safe_staging/finalized/.overlay_consistent').exists()
|
||||
with sqlite3.connect(backup / 'model_stats.sqlite') as saved:
|
||||
assert saved.execute('PRAGMA integrity_check').fetchone() == ('ok',)
|
||||
assert saved.execute('SELECT * FROM events').fetchall() == [(1, 'before install')]
|
||||
assert (backup / 'params/AutomaticUpdates').read_bytes() == b'1'
|
||||
state.connection.execute('INSERT INTO events VALUES (2, "after install")')
|
||||
state.connection.commit()
|
||||
write(state.data, 'params/d/ExampleSetting', 'newer preference')
|
||||
|
||||
# Load only the saved helper in an isolated interpreter. Its real standalone
|
||||
# restore needs no checkout imports; redirect its parked root and process
|
||||
# discovery because CLI defaults intentionally refer to an actual device.
|
||||
recovery_script = '''
|
||||
import importlib.util, pathlib, sys
|
||||
backup = pathlib.Path(sys.argv[1])
|
||||
spec = importlib.util.spec_from_file_location('saved_recovery', backup / 'recover.py')
|
||||
helper = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(helper)
|
||||
helper._processes = lambda: {}
|
||||
def no_signal(*args):
|
||||
raise AssertionError('Recovery rehearsal must never signal a process')
|
||||
helper.os.kill = no_signal
|
||||
with helper.suspend_updater() as control:
|
||||
restored = helper.restore(backup, check_parked=lambda: helper.require_parked(pathlib.Path(sys.argv[2])))
|
||||
assert control.restart
|
||||
print(restored['commit'])
|
||||
'''
|
||||
result = subprocess.run([sys.executable, '-I', '-c', recovery_script, str(backup), str(state.data)],
|
||||
cwd=state.root, text=True, capture_output=True, timeout=60, check=True)
|
||||
assert result.stdout.strip() == state.latest
|
||||
assert git(state.repo, 'rev-parse', 'HEAD') == state.latest
|
||||
assert (state.repo / 'feature.txt').read_text() == 'valuable local changes\n'
|
||||
assert (state.repo / 'local-tool.txt').read_text() == 'untracked local tool\n'
|
||||
assert state.installer.read_pin(state.repo, state.data) is None
|
||||
assert state.connection.execute('SELECT * FROM events').fetchall() == [(1, 'before install'), (2, 'after install')]
|
||||
assert (state.data / 'params/d/ExampleSetting').read_text() == 'newer preference'
|
||||
|
||||
install_target(state, fetch_target(state, state.old))
|
||||
target = fetch_target(state, 'latest')
|
||||
assert target == {'branch': 'Dom', 'commit': state.latest, 'head': state.latest, 'pinned': False}
|
||||
assert state.requests.count('local-rehearsal/branches/Dom') == 3
|
||||
install_target(state, target)
|
||||
assert git(state.repo, 'rev-parse', 'HEAD') == state.latest
|
||||
assert (state.repo / 'feature.txt').read_text() == 'latest\n'
|
||||
assert state.installer.read_pin(state.repo, state.data) is None
|
||||
assert not (state.data / 'starpilot/version_selection.json').exists()
|
||||
assert (state.data / 'params/d/AutomaticUpdates').read_bytes() == b'0'
|
||||
assert (state.data / 'params/d/ExampleSetting').read_text() == 'newer preference'
|
||||
assert state.connection.execute('SELECT count(*) FROM events').fetchone() == (2,)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('guard', ['installed_os', 'firmware_variant', 'native_artifact'])
|
||||
def test_native_preflight_guards_leave_local_source_and_data_untouched(rehearsal, guard):
|
||||
state = rehearsal
|
||||
target = fetch_target(state, state.old)
|
||||
if guard == 'installed_os':
|
||||
state.version_file.write_text('18.0\n')
|
||||
expected = 'running device has AGNOS'
|
||||
else:
|
||||
# Create a deliberately incompatible target in the LOCAL origin and fetch
|
||||
# it through the same exact-SHA path, retaining the previous local checkout.
|
||||
seed = state.root / 'seed'
|
||||
git(seed, 'checkout', '--detach', state.old)
|
||||
name = ('panda/board/obj/panda_h7_tesla_wake.bin.signed' if guard == 'firmware_variant'
|
||||
else 'system/camerad/camerad')
|
||||
write(seed, name, b'')
|
||||
git(seed, 'add', name)
|
||||
git(seed, 'commit', '-m', 'Deliberately incompatible local target')
|
||||
bad = git(seed, 'rev-parse', 'HEAD')
|
||||
git(seed, 'push', str(state.origin), 'HEAD:refs/heads/guard-fixture')
|
||||
git(state.repo, *state.fetch_args(bad))
|
||||
target = dict(target, commit=bad)
|
||||
expected = 'firmware settings' if guard == 'firmware_variant' else 'compatible ARM64 artifact'
|
||||
with pytest.raises(state.installer.InstallError, match=expected):
|
||||
install_target(state, target)
|
||||
assert git(state.repo, 'rev-parse', 'HEAD') == state.latest
|
||||
assert (state.repo / 'feature.txt').read_text() == 'valuable local changes\n'
|
||||
assert (state.repo / 'local-tool.txt').read_text() == 'untracked local tool\n'
|
||||
assert (state.data / 'params/d/AutomaticUpdates').read_bytes() == b'1'
|
||||
assert (state.data / 'params/d/TeslaWakeOnCAN').read_bytes() == b'1'
|
||||
assert (state.data / 'params/d/ExampleSetting').read_bytes() == b'preserve me'
|
||||
assert state.connection.execute('SELECT * FROM events').fetchall() == [(1, 'before install')]
|
||||
assert (state.data / 'safe_staging/finalized/.overlay_consistent').exists()
|
||||
assert not (state.data / 'starpilot/version-backups').exists()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Exercise the real route/worker bodies without loading vehicle daemons."""
|
||||
import ast
|
||||
from contextlib import nullcontext
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import time
|
||||
from unittest.mock import Mock
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
import pytest
|
||||
|
||||
MODULE_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
def load_module(name):
|
||||
spec = importlib.util.spec_from_file_location(name, MODULE_DIR / (name + '.py'))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
app = Flask(__name__)
|
||||
history, installer = load_module('version_history'), load_module('version_install')
|
||||
ns = dict(app=app, jsonify=jsonify, request=request, time=time, re=__import__('re'), Path=Path,
|
||||
version_history=history, version_install=installer, threading=SimpleNamespace(Thread=Mock()),
|
||||
_fast_update_lock=threading.Lock(), _fast_update_state={'running': False}, _FAST_UPDATE_TOTAL_STEPS=5,
|
||||
_get_openpilot_root=lambda: '/repo', _is_valid_git_branch_name=lambda repo, branch: branch in ('Dom','StarPilot','feature/test'),
|
||||
_get_fast_update_state=lambda: {}, _set_fast_update_state=Mock(), _set_fast_update_progress=Mock(),
|
||||
_set_fast_update_error_state=Mock(), _remote_git_check_allowed=lambda: True,
|
||||
_git_stdout=Mock(return_value='a'*40), _run_git_with_progress=Mock(return_value=(0,'')),
|
||||
_build_shallow_fetch_commit_args=lambda sha: ['fetch','--depth=1','origin',sha],
|
||||
_save_rollback_target=Mock(), update_starpilot_toggles=Mock(), HARDWARE=SimpleNamespace(reboot=Mock()),
|
||||
_FAST_UPDATE_REBOOT_NOTICE_SECONDS=0)
|
||||
history.resolve_version=Mock(return_value={'branch':'Dom','commit':'a'*40,'head':'b'*40,'pinned':True})
|
||||
history.list_versions=Mock(return_value={'branch':'Dom','head':'b'*40,'page':1,'hasMore':False,'commits':[]})
|
||||
installer.require_parked=Mock()
|
||||
installer.updater_control=SimpleNamespace(restart_after_install=Mock())
|
||||
installer.suspend_updater=lambda: nullcontext(installer.updater_control)
|
||||
installer.check_repository_idle=Mock()
|
||||
installer.install=Mock(return_value={'backup':'/data/test','commit':'a'*40,'branch':'Dom','pinned':True})
|
||||
tree=ast.parse((MODULE_DIR/'the_galaxy.py').read_text())
|
||||
names={'get_update_versions','run_version_install','_version_install_worker'}
|
||||
found=[node for node in ast.walk(tree) if isinstance(node,ast.FunctionDef) and node.name in names]
|
||||
assert len(found)==len(names),'Version routes and worker not implemented'
|
||||
for node in sorted(found,key=lambda n:n.name):
|
||||
exec(compile(ast.Module(body=[node],type_ignores=[]),str(MODULE_DIR/'the_galaxy.py'),'exec'),ns)
|
||||
return app.test_client(),ns
|
||||
|
||||
def test_history_uses_branch_and_pinned_pagination(server):
|
||||
client,ns=server
|
||||
reply=client.get('/api/update/versions?branch=feature/test&page=2&head='+'b'*40)
|
||||
assert reply.status_code==200
|
||||
ns['version_history'].list_versions.assert_called_once_with('/repo','feature/test',page=2,head='b'*40)
|
||||
|
||||
@pytest.mark.parametrize('body',[{},[],{'branch':'Dom','commit':'a'*40}, {'branch':'Dom','commit':'HEAD','confirmed':True}, {'branch':'-evil','commit':'latest','confirmed':True}, {'branch':'Dom','commit':'latest','confirmed':'true'}])
|
||||
def test_invalid_install_never_starts_worker(server,body):
|
||||
client,ns=server
|
||||
assert client.post('/api/update/version',json=body).status_code==400
|
||||
ns['threading'].Thread.assert_not_called()
|
||||
|
||||
def test_install_requires_known_parked_state(server):
|
||||
client,ns=server
|
||||
ns['version_install'].require_parked.side_effect=ns['version_install'].InstallError('Park first')
|
||||
assert client.post('/api/update/version',json={'branch':'Dom','commit':'latest','confirmed':True}).status_code==409
|
||||
ns['threading'].Thread.assert_not_called()
|
||||
|
||||
def test_concurrent_install_rejected(server):
|
||||
client,ns=server
|
||||
ns['_fast_update_state']['running']=True
|
||||
assert client.post('/api/update/version',json={'branch':'Dom','commit':'latest','confirmed':True}).status_code==409
|
||||
ns['threading'].Thread.assert_not_called()
|
||||
|
||||
def test_accepted_selection_keeps_exact_sha(server):
|
||||
client,ns=server
|
||||
assert client.post('/api/update/version',json={'branch':'Dom','commit':'c'*40,'confirmed':True}).status_code==202
|
||||
assert ns['_fast_update_state']['running'] is True
|
||||
assert ns['threading'].Thread.call_args.kwargs['args']==('Dom','c'*40)
|
||||
|
||||
def test_worker_refuses_fetch_mismatch_before_checkout_or_reboot(server):
|
||||
_,ns=server
|
||||
ns['_git_stdout'].return_value='d'*40
|
||||
ns['_version_install_worker']('Dom','a'*40)
|
||||
ns['version_install'].install.assert_not_called()
|
||||
ns['HARDWARE'].reboot.assert_not_called()
|
||||
ns['_set_fast_update_error_state'].assert_called_once()
|
||||
|
||||
def test_worker_preserves_selected_sha_and_rechecks_parked(server):
|
||||
_,ns=server
|
||||
ns['_version_install_worker']('Dom','a'*40)
|
||||
ns['version_history'].resolve_version.assert_called_once_with('/repo','Dom','a'*40)
|
||||
assert ns['_run_git_with_progress'].call_args.args[1][-1]=='a'*40
|
||||
assert ns['version_install'].install.call_args.args[1]['commit']=='a'*40
|
||||
assert ns['version_install'].require_parked.call_count>=3
|
||||
ns['HARDWARE'].reboot.assert_called_once()
|
||||
|
||||
def test_resolution_failure_does_not_fetch_or_change_source(server):
|
||||
_,ns=server
|
||||
ns['version_history'].resolve_version.side_effect=RuntimeError('Branch rewritten')
|
||||
ns['_version_install_worker']('Dom','a'*40)
|
||||
ns['_run_git_with_progress'].assert_not_called()
|
||||
ns['version_install'].install.assert_not_called()
|
||||
ns['HARDWARE'].reboot.assert_not_called()
|
||||
|
||||
def test_post_install_failure_reports_installed_revision_and_restarts_updater(server):
|
||||
_,ns=server
|
||||
ns['HARDWARE'].reboot.side_effect=RuntimeError('reboot denied')
|
||||
ns['_version_install_worker']('Dom','a'*40)
|
||||
ns['version_install'].updater_control.restart_after_install.assert_called_once()
|
||||
assert 'is installed' in ns['_set_fast_update_error_state'].call_args.args[0]
|
||||
ns['_set_fast_update_state'].assert_called_with(recoveryBackup='/data/test')
|
||||
|
||||
def test_install_failure_never_marks_success_or_reboots(server):
|
||||
_,ns=server
|
||||
ns['version_install'].install.side_effect=RuntimeError('disk full')
|
||||
ns['_version_install_worker']('Dom','a'*40)
|
||||
ns['version_install'].updater_control.restart_after_install.assert_not_called()
|
||||
ns['HARDWARE'].reboot.assert_not_called()
|
||||
|
||||
def test_history_has_no_artificial_page_depth_cutoff(server):
|
||||
client,ns=server
|
||||
reply=client.get('/api/update/versions?branch=Dom&page=1201&head='+'b'*40)
|
||||
assert reply.status_code==200
|
||||
ns['version_history'].list_versions.assert_called_once_with('/repo','Dom',page=1201,head='b'*40)
|
||||
@@ -77,6 +77,7 @@ from openpilot.starpilot.common.model_lab import (
|
||||
)
|
||||
from openpilot.starpilot.assets.theme_manager import HOLIDAY_THEME_PATH, THEME_COMPONENT_PARAMS
|
||||
from openpilot.starpilot.common import param_profiles
|
||||
from openpilot.starpilot.system.the_galaxy import version_history, version_install
|
||||
from openpilot.starpilot.common.accel_profile import (
|
||||
A_CRUISE_MAX_BP_CUSTOM,
|
||||
CUSTOM_ACCEL_PROFILE_BREAKPOINT_PARAM_KEYS,
|
||||
@@ -3014,6 +3015,60 @@ def _collect_fast_update_info(include_remote=True):
|
||||
**rollback_data,
|
||||
}
|
||||
|
||||
def _version_install_worker(branch, selection):
|
||||
"""Resolve, back up and install one immutable revision after explicit confirmation."""
|
||||
repo_path = str(_get_openpilot_root())
|
||||
installed = None
|
||||
try:
|
||||
version_install.require_parked()
|
||||
_set_fast_update_progress(1, "Resolving selected version", 10.0, branch)
|
||||
target = version_history.resolve_version(repo_path, branch, selection)
|
||||
version_install.require_parked()
|
||||
# The normal updater owns staging for its whole lifetime. Pause only that
|
||||
# daemon and its children, then refuse any still-held Git locks.
|
||||
with version_install.suspend_updater() as updater:
|
||||
version_install.require_parked()
|
||||
version_install.check_repository_idle(repo_path)
|
||||
rc, detail = _run_git_with_progress(
|
||||
repo_path, _build_shallow_fetch_commit_args(target["commit"]),
|
||||
timeout=240, step=1, label="Fetching selected version",
|
||||
)
|
||||
if rc:
|
||||
raise version_install.InstallError(detail or "Unable to fetch the selected revision")
|
||||
if _git_stdout(repo_path, ["rev-parse", "FETCH_HEAD^{commit}"]) != target["commit"]:
|
||||
raise version_install.InstallError("Fetched revision does not match the selected version")
|
||||
version_install.require_parked()
|
||||
previous_branch = _git_stdout(repo_path, ["branch", "--show-current"])
|
||||
previous_commit = _git_stdout(repo_path, ["rev-parse", "HEAD"])
|
||||
result = version_install.install(
|
||||
repo_path, target, check_parked=version_install.require_parked,
|
||||
progress=_set_fast_update_progress,
|
||||
)
|
||||
installed = result
|
||||
updater.restart_after_install()
|
||||
_save_rollback_target(repo_path, previous_branch, previous_commit)
|
||||
update_starpilot_toggles()
|
||||
version_install.require_parked()
|
||||
_set_fast_update_progress(5, "Rebooting device", 100.0, "Selected revision installed. Automatic updates remain paused.")
|
||||
# Keep the action locked until shutdown. Another request must not start
|
||||
# during the reboot notice, or if the hardware reboot call returns.
|
||||
_set_fast_update_state(
|
||||
running=True, stage="rebooting", finishedAt=time.time(),
|
||||
message=f"Installed {branch} @ {target['commit'][:10]}. Rebooting now.",
|
||||
recoveryBackup=result["backup"],
|
||||
)
|
||||
time.sleep(_FAST_UPDATE_REBOOT_NOTICE_SECONDS)
|
||||
version_install.require_parked()
|
||||
HARDWARE.reboot()
|
||||
except Exception as exception:
|
||||
if installed is not None:
|
||||
_set_fast_update_error_state(
|
||||
"The selected version is installed, but the device could not finish restarting. Park and reboot to activate it.", exception,
|
||||
)
|
||||
_set_fast_update_state(recoveryBackup=installed["backup"])
|
||||
else:
|
||||
_set_fast_update_error_state("Selected version installation failed.", exception)
|
||||
|
||||
def _fast_update_worker():
|
||||
started_at = time.time()
|
||||
repo_path = str(_get_openpilot_root())
|
||||
@@ -8555,6 +8610,7 @@ def setup(app):
|
||||
**git_data,
|
||||
"isOnroad": _safe_params_get_bool("IsOnroad"),
|
||||
"automaticUpdates": _safe_params_get_bool("AutomaticUpdates"),
|
||||
"versionPin": version_install.read_pin(repo_path),
|
||||
"interruptedUpdateRecovery": _get_interrupted_update_recovery(repo_path, state_data),
|
||||
"warning": "Fast update skips backup creation and finalization safeguards.",
|
||||
}), 200
|
||||
@@ -8622,6 +8678,61 @@ def setup(app):
|
||||
"running": state_data.get("running", False),
|
||||
}), 200
|
||||
|
||||
@app.route("/api/update/versions", methods=["GET"])
|
||||
def get_update_versions():
|
||||
branch = request.args.get("branch", "")
|
||||
repo_path = str(_get_openpilot_root())
|
||||
if not branch or len(branch) > 255 or not _is_valid_git_branch_name(repo_path, branch):
|
||||
return jsonify({"error": "Choose a valid branch to browse its versions."}), 400
|
||||
try:
|
||||
page = int(request.args.get("page", "1"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid history page."}), 400
|
||||
head = request.args.get("head") or None
|
||||
if page < 1 or (head is not None and not re.fullmatch(r"[0-9a-f]{40}", head)) or (page > 1 and head is None):
|
||||
return jsonify({"error": "Invalid history page or history revision."}), 400
|
||||
if not _remote_git_check_allowed():
|
||||
return jsonify({"error": "Version history will be available once the device clock is synchronized."}), 503
|
||||
try:
|
||||
return jsonify(version_history.list_versions(repo_path, branch, page=page, head=head)), 200
|
||||
except version_history.VersionHistoryError as exception:
|
||||
return jsonify({"error": str(exception)}), 422
|
||||
except Exception:
|
||||
return jsonify({"error": "Unable to load version history. Please try again."}), 503
|
||||
|
||||
@app.route("/api/update/version", methods=["POST"])
|
||||
def run_version_install():
|
||||
payload = request.get_json(silent=True)
|
||||
if not isinstance(payload, dict) or payload.get("confirmed") is not True:
|
||||
return jsonify({"error": "Confirm the selected version before installing."}), 400
|
||||
branch, commit = payload.get("branch"), payload.get("commit")
|
||||
repo_path = str(_get_openpilot_root())
|
||||
if not isinstance(branch, str) or not branch or len(branch) > 255 or not _is_valid_git_branch_name(repo_path, branch):
|
||||
return jsonify({"error": "Invalid branch name."}), 400
|
||||
if not isinstance(commit, str) or (commit != "latest" and not re.fullmatch(r"[0-9a-f]{40}", commit)):
|
||||
return jsonify({"error": "Choose Latest or an exact version from this branch's history."}), 400
|
||||
try:
|
||||
version_install.require_parked()
|
||||
except version_install.InstallError as exception:
|
||||
return jsonify({"error": str(exception)}), 409
|
||||
with _fast_update_lock:
|
||||
if _fast_update_state.get("running"):
|
||||
return jsonify({"error": "Another update action is already in progress."}), 409
|
||||
_fast_update_state.update({
|
||||
"running": True, "stage": "starting", "message": "Preparing selected version...",
|
||||
"lastError": "", "lastBranch": branch, "lastMode": "version-install",
|
||||
"startedAt": time.time(), "finishedAt": 0.0,
|
||||
"progressStep": 1, "progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
|
||||
"progressStepPercent": 0.0, "progressPercent": 0.0,
|
||||
"progressLabel": "Preparing selected version", "progressDetail": "Checking branch history and compatibility...",
|
||||
})
|
||||
try:
|
||||
threading.Thread(target=_version_install_worker, args=(branch, commit), daemon=True).start()
|
||||
except Exception as exception:
|
||||
_set_fast_update_error_state("Unable to start version installation.", exception)
|
||||
return jsonify({"error": "Unable to start version installation."}), 503
|
||||
return jsonify({"message": "Version installation started. The device will reboot when complete."}), 202
|
||||
|
||||
@app.route("/api/update/agnos_status", methods=["GET"])
|
||||
def get_agnos_update_status():
|
||||
state_data = _get_fast_update_state()
|
||||
@@ -8671,6 +8782,8 @@ def setup(app):
|
||||
def run_fast_update():
|
||||
if params.get_bool("IsOnroad"):
|
||||
return jsonify({"error": "Cannot run a fast update while driving."}), 409
|
||||
if version_install.read_pin(str(_get_openpilot_root())):
|
||||
return jsonify({"error": "A historical version is installed. Choose Latest in the version selector to update."}), 409
|
||||
|
||||
with _fast_update_lock:
|
||||
if _fast_update_state.get("running"):
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Bounded public GitHub metadata for the configured origin; never mutates Git."""
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
from http.client import HTTPException
|
||||
from math import ceil
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urlencode, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
|
||||
PAGE_SIZE = 25
|
||||
CACHE_TTL = 60
|
||||
IMMUTABLE_CACHE_TTL = 6 * 60 * 60
|
||||
MAX_CACHE_ENTRIES = 64
|
||||
MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
||||
HTTP_TIMEOUT = 10
|
||||
_BLOCK_SIZE = 100
|
||||
_cache = OrderedDict()
|
||||
_cache_lock = threading.Lock()
|
||||
_api_backoff_until = 0.0
|
||||
_api_retry_at = 0.0
|
||||
VERSION_CACHE_TTL = 6 * 60 * 60
|
||||
MAX_VERSION_CACHE_ENTRIES = 1024
|
||||
MAX_VERSION_BYTES = 64 * 1024
|
||||
_VERSION_WORKERS = 4
|
||||
_version_cache = OrderedDict()
|
||||
_version_slots = threading.BoundedSemaphore(_VERSION_WORKERS)
|
||||
SNAPSHOT_CACHE_DIR = Path('/data/starpilot/cache/version-history')
|
||||
MAX_SNAPSHOT_FILES = 256
|
||||
MAX_SNAPSHOT_BYTES = 128 * 1024
|
||||
MAX_SNAPSHOT_TOTAL_BYTES = 16 * 1024 * 1024
|
||||
SNAPSHOT_RETENTION = 30 * 86400
|
||||
_snapshot_lock = threading.Lock()
|
||||
|
||||
|
||||
class VersionHistoryError(Exception):
|
||||
"""An invalid selection or actionable public metadata lookup failure."""
|
||||
|
||||
|
||||
class HistoryUnavailable(VersionHistoryError):
|
||||
"""A transport or quota failure that permits a labelled browsing snapshot."""
|
||||
|
||||
|
||||
class _GitHubRedirectHandler(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
target = urlsplit(newurl)
|
||||
if target.scheme != 'https' or target.netloc != 'api.github.com':
|
||||
raise VersionHistoryError('GitHub returned an unsupported metadata redirect; retry later.')
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
_open_url = build_opener(_GitHubRedirectHandler()).open
|
||||
|
||||
|
||||
class _RawGitHubRedirectHandler(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
target = urlsplit(newurl)
|
||||
if target.scheme != 'https' or target.netloc != 'raw.githubusercontent.com':
|
||||
raise VersionHistoryError('GitHub returned an unsupported version metadata redirect; retry later.')
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
_open_raw_url = build_opener(_RawGitHubRedirectHandler()).open
|
||||
|
||||
|
||||
def _is_quota_failure(error):
|
||||
headers = error.headers or {}
|
||||
wait = headers.get('Retry-After', '').strip()
|
||||
return error.code == 429 or (error.code == 403 and
|
||||
(headers.get('X-RateLimit-Remaining') == '0' or (wait.isdigit() and len(wait) <= 6)))
|
||||
|
||||
|
||||
def _get_display_version(url):
|
||||
"""Read only the numeric version literal; never import or execute remote code."""
|
||||
request = Request(url, headers={'User-Agent': 'StarPilot-Galaxy-VersionPicker'})
|
||||
try:
|
||||
with _open_raw_url(request, timeout=HTTP_TIMEOUT) as response:
|
||||
raw = response.read(MAX_VERSION_BYTES + 1)
|
||||
if len(raw) > MAX_VERSION_BYTES:
|
||||
raise VersionHistoryError('GitHub version metadata is too large; retry later.')
|
||||
source = raw.decode('utf-8')
|
||||
assignments = re.findall(r'^STARPILOT_DISPLAY_VERSION\b[^\r\n]*', source, re.MULTILINE)
|
||||
if len(assignments) != 1:
|
||||
raise ValueError()
|
||||
match = re.fullmatch(r"""STARPILOT_DISPLAY_VERSION[ \t]*=[ \t]*(['"])((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))\1[ \t]*(?:#[^\r\n]*)?""", assignments[0])
|
||||
if match is None:
|
||||
raise ValueError()
|
||||
return match.group(2)
|
||||
except HTTPError as error:
|
||||
error.close()
|
||||
if error.code == 404:
|
||||
return None # Older builds can predate the display-version file.
|
||||
if error.code in (403, 429):
|
||||
failure = HistoryUnavailable if _is_quota_failure(error) else VersionHistoryError
|
||||
raise failure('GitHub version metadata access is limited; retry later.') from None
|
||||
failure = HistoryUnavailable if 500 <= error.code < 600 else VersionHistoryError
|
||||
raise failure('GitHub version metadata is unavailable; retry later.') from None
|
||||
except (URLError, TimeoutError, OSError, HTTPException):
|
||||
raise HistoryUnavailable('Cannot reach GitHub version metadata; check connectivity and retry.') from None
|
||||
except (ValueError, UnicodeError):
|
||||
raise VersionHistoryError('GitHub returned invalid version metadata; retry later.') from None
|
||||
|
||||
|
||||
def _display_version(base, sha):
|
||||
# The validated origin and full SHA make this an immutable public file URL.
|
||||
repository = base.removeprefix('https://api.github.com/repos/')
|
||||
url = f'https://raw.githubusercontent.com/{repository}/{sha}/selfdrive/ui/lib/starpilot_version.py'
|
||||
# Share the limit across requests, and recheck the cache after acquiring a slot.
|
||||
with _version_slots:
|
||||
with _cache_lock:
|
||||
entry = _version_cache.get(url)
|
||||
if entry is not None and time.monotonic() - entry[0] < VERSION_CACHE_TTL:
|
||||
_version_cache.move_to_end(url)
|
||||
return entry[1]
|
||||
value = _get_display_version(url)
|
||||
with _cache_lock:
|
||||
_version_cache[url] = (time.monotonic(), value)
|
||||
_version_cache.move_to_end(url)
|
||||
while len(_version_cache) > MAX_VERSION_CACHE_ENTRIES:
|
||||
_version_cache.popitem(last=False)
|
||||
return value
|
||||
|
||||
|
||||
def _rate_limit_message(retry_at):
|
||||
seconds = max(0, ceil(retry_at - time.time()))
|
||||
if seconds >= 120:
|
||||
suffix = f' in about {ceil(seconds / 60)} minutes'
|
||||
elif seconds:
|
||||
suffix = f' in {seconds} seconds'
|
||||
else:
|
||||
suffix = ' later'
|
||||
return f'GitHub rate limit or public access restriction; retry{suffix}.'
|
||||
|
||||
|
||||
def _get_json(url):
|
||||
"""Fetch one bounded public API response. Kept separate for offline tests."""
|
||||
global _api_backoff_until, _api_retry_at
|
||||
with _cache_lock:
|
||||
if time.monotonic() < _api_backoff_until:
|
||||
raise HistoryUnavailable(_rate_limit_message(_api_retry_at))
|
||||
request = Request(url, headers={'Accept': 'application/vnd.github+json',
|
||||
'User-Agent': 'StarPilot-Galaxy-VersionPicker',
|
||||
'X-GitHub-Api-Version': '2022-11-28'})
|
||||
try:
|
||||
with _open_url(request, timeout=HTTP_TIMEOUT) as response:
|
||||
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
||||
if len(raw) > MAX_RESPONSE_BYTES:
|
||||
raise VersionHistoryError('GitHub metadata response is too large; retry later.')
|
||||
return json.loads(raw)
|
||||
except HTTPError as error:
|
||||
error.close()
|
||||
if error.code in (403, 429):
|
||||
headers = error.headers or {}
|
||||
wait = headers.get('Retry-After', '').strip()
|
||||
reset = headers.get('X-RateLimit-Reset', '').strip()
|
||||
retry_at = 0.0
|
||||
if wait.isdigit() and len(wait) <= 6:
|
||||
retry_at = time.time() + int(wait)
|
||||
elif reset.isdigit() and len(reset) <= 12 and int(reset) > time.time():
|
||||
retry_at = float(reset)
|
||||
# Share only a short quota backoff; unrelated 403 responses do not block
|
||||
# other lookups. Valid cached responses remain available during backoff.
|
||||
if _is_quota_failure(error):
|
||||
delay = max(1, min(60, retry_at - time.time())) if retry_at else 60
|
||||
with _cache_lock:
|
||||
_api_backoff_until = time.monotonic() + delay
|
||||
_api_retry_at = retry_at
|
||||
failure = HistoryUnavailable if _is_quota_failure(error) else VersionHistoryError
|
||||
raise failure(_rate_limit_message(retry_at)) from None
|
||||
if error.code == 404:
|
||||
raise VersionHistoryError('GitHub repository, branch, or commit not found; refresh and retry.') from None
|
||||
failure = HistoryUnavailable if 500 <= error.code < 600 else VersionHistoryError
|
||||
raise failure('GitHub metadata is unavailable; retry later.') from None
|
||||
except (URLError, TimeoutError, OSError, HTTPException):
|
||||
raise HistoryUnavailable('Cannot reach GitHub metadata; check connectivity and retry.') from None
|
||||
except (ValueError, UnicodeError, RecursionError):
|
||||
raise VersionHistoryError('GitHub returned invalid metadata; retry later.') from None
|
||||
|
||||
|
||||
def _json(url, fresh=False, ttl=CACHE_TTL):
|
||||
now = time.monotonic()
|
||||
if not fresh:
|
||||
with _cache_lock:
|
||||
entry = _cache.get(url)
|
||||
if entry is not None and now - entry[0] < ttl:
|
||||
_cache.move_to_end(url)
|
||||
return copy.deepcopy(entry[1])
|
||||
value = _get_json(url)
|
||||
with _cache_lock:
|
||||
_cache[url] = (time.monotonic(), copy.deepcopy(value))
|
||||
_cache.move_to_end(url)
|
||||
while len(_cache) > MAX_CACHE_ENTRIES:
|
||||
_cache.popitem(last=False)
|
||||
return value
|
||||
|
||||
|
||||
def _git(repo_path, *args):
|
||||
try:
|
||||
result = subprocess.run(['git', '-C', str(repo_path), *args], capture_output=True, text=True, timeout=5, check=True)
|
||||
return result.stdout.strip()
|
||||
except (subprocess.SubprocessError, OSError, ValueError):
|
||||
raise VersionHistoryError('Cannot read Git origin or validate branch in this repository.') from None
|
||||
|
||||
|
||||
def _branch(repo_path, branch):
|
||||
if not isinstance(branch, str) or not branch or len(branch) > 255 or branch.startswith('-') or '@{' in branch:
|
||||
raise VersionHistoryError('Invalid branch name.')
|
||||
if _git(repo_path, 'check-ref-format', '--branch', branch) != branch:
|
||||
raise VersionHistoryError('Invalid branch name.')
|
||||
return branch
|
||||
|
||||
|
||||
def _sha(value, label='commit'):
|
||||
if not isinstance(value, str) or re.fullmatch(r'[0-9a-fA-F]{40}', value) is None:
|
||||
raise VersionHistoryError(f'Invalid {label}; expected a full 40-character commit SHA.')
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _repository(repo_path):
|
||||
remote = _git(repo_path, 'config', '--get', 'remote.origin.url')
|
||||
ssh = re.fullmatch(r'git@github\.com:([^/]+)/([^/]+)', remote)
|
||||
if ssh:
|
||||
owner, name = ssh.groups()
|
||||
else:
|
||||
try:
|
||||
parsed = urlsplit(remote)
|
||||
except ValueError:
|
||||
raise VersionHistoryError('Configured origin must be a public GitHub repository.') from None
|
||||
allowed = ((parsed.scheme == 'https' and parsed.netloc == 'github.com') or
|
||||
(parsed.scheme == 'ssh' and parsed.netloc == 'git@github.com'))
|
||||
parts = parsed.path.strip('/').split('/')
|
||||
if not allowed or parsed.query or parsed.fragment or len(parts) != 2:
|
||||
raise VersionHistoryError('Configured origin must be a public GitHub repository.')
|
||||
owner, name = parts
|
||||
name = name.removesuffix('.git')
|
||||
if not re.fullmatch(r'[A-Za-z0-9-]+', owner) or not re.fullmatch(r'[A-Za-z0-9_.-]+', name) or name in ('.', '..'):
|
||||
raise VersionHistoryError('Configured origin must be a public GitHub repository.')
|
||||
return f'https://api.github.com/repos/{owner}/{name}'
|
||||
|
||||
|
||||
def _head(base, branch, fresh=False):
|
||||
data = _json(f'{base}/branches/{quote(branch, safe="")}', fresh=fresh)
|
||||
try:
|
||||
if data['name'] != branch:
|
||||
raise ValueError()
|
||||
return _sha(data['commit']['sha'], 'GitHub branch head')
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise VersionHistoryError('GitHub returned invalid branch metadata; retry later.') from None
|
||||
|
||||
|
||||
def _ancestor(base, chosen, head):
|
||||
if chosen == head:
|
||||
return
|
||||
data = _json(f'{base}/compare/{chosen}...{head}', ttl=IMMUTABLE_CACHE_TTL)
|
||||
try:
|
||||
valid = data['status'] in ('ahead', 'identical') and _sha(data['merge_base_commit']['sha'], 'GitHub merge base') == chosen
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise VersionHistoryError('GitHub returned invalid branch ancestry; retry later.') from None
|
||||
if not valid:
|
||||
raise VersionHistoryError('Selected commit is no longer in this branch history. Refresh the branch and choose again.')
|
||||
|
||||
|
||||
def _block(base, head, page):
|
||||
data = _json(f'{base}/commits?{urlencode({"sha": head, "per_page": _BLOCK_SIZE, "page": page})}', ttl=IMMUTABLE_CACHE_TTL)
|
||||
if not isinstance(data, list) or len(data) > _BLOCK_SIZE:
|
||||
raise VersionHistoryError('GitHub returned invalid commit history; retry later.')
|
||||
rows = []
|
||||
seen = set()
|
||||
try:
|
||||
for item in data:
|
||||
sha = _sha(item['sha'], 'GitHub commit')
|
||||
message = item['commit']['message']
|
||||
date = item['commit']['committer']['date']
|
||||
if not isinstance(message, str) or not message.strip() or not isinstance(date, str) or len(date) > 64 or sha in seen:
|
||||
raise ValueError()
|
||||
if datetime.fromisoformat(date.replace('Z', '+00:00')).tzinfo is None:
|
||||
raise ValueError()
|
||||
rows.append({'sha': sha, 'subject': message.splitlines()[0][:300], 'date': date})
|
||||
seen.add(sha)
|
||||
if page == 1 and (not rows or rows[0]['sha'] != head):
|
||||
raise ValueError()
|
||||
except (KeyError, IndexError, TypeError, ValueError):
|
||||
raise VersionHistoryError('GitHub returned invalid commit metadata; retry later.') from None
|
||||
return rows
|
||||
|
||||
|
||||
def _snapshot_path(base, branch, page, requested_head):
|
||||
key = json.dumps([base, branch, page, requested_head], separators=(',', ':')).encode()
|
||||
return SNAPSHOT_CACHE_DIR / (hashlib.sha256(key).hexdigest() + '.json')
|
||||
|
||||
|
||||
def _snapshot_time(value):
|
||||
if not isinstance(value, str) or len(value) > 64:
|
||||
raise ValueError()
|
||||
parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError()
|
||||
return parsed.timestamp()
|
||||
|
||||
|
||||
def _validate_snapshot(data, base, branch, page, requested_head):
|
||||
if (data['repository'], data['branch'], data['page'], data['requestedHead']) != (base, branch, page, requested_head):
|
||||
raise ValueError()
|
||||
if type(page) is not int or page < 1 or type(data['page']) is not int or (page > 1 and requested_head is None):
|
||||
raise ValueError()
|
||||
if requested_head is not None:
|
||||
_sha(requested_head)
|
||||
stamp = _snapshot_time(data['cachedAt'])
|
||||
if not -60 <= time.time() - stamp <= SNAPSHOT_RETENTION:
|
||||
raise ValueError()
|
||||
result = data['result']
|
||||
head = _sha(result['head'])
|
||||
if (result['branch'] != branch or type(result['page']) is not int or result['page'] != page or
|
||||
type(result['hasMore']) is not bool or result.get('cached') or (requested_head is not None and head != requested_head)):
|
||||
raise ValueError()
|
||||
rows = result['commits']
|
||||
if not isinstance(rows, list) or len(rows) > PAGE_SIZE:
|
||||
raise ValueError()
|
||||
selected, seen = [], set()
|
||||
for row in rows:
|
||||
sha = _sha(row['sha'])
|
||||
if sha in seen or not isinstance(row['subject'], str) or not row['subject'].strip() or len(row['subject']) > 300:
|
||||
raise ValueError()
|
||||
_snapshot_time(row['date'])
|
||||
clean = {'sha': sha, 'subject': row['subject'], 'date': row['date']}
|
||||
if branch.lower() == 'starpilot' or 'version' in row:
|
||||
version = row['version']
|
||||
if version is not None and (not isinstance(version, str) or len(version) > 64 or
|
||||
re.fullmatch(r'(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)', version) is None):
|
||||
raise ValueError()
|
||||
clean['version'] = version
|
||||
selected.append(clean)
|
||||
seen.add(sha)
|
||||
if page == 1 and (not selected or selected[0]['sha'] != head):
|
||||
raise ValueError()
|
||||
return {'branch': branch, 'head': head, 'page': page, 'hasMore': result['hasMore'], 'commits': selected, 'cachedAt': data['cachedAt']}
|
||||
|
||||
|
||||
def _load_history_snapshot(base, branch, page, requested_head):
|
||||
"""Return a validated disk snapshot, or None; never validates installation."""
|
||||
try:
|
||||
path = _snapshot_path(base, branch, page, requested_head)
|
||||
if SNAPSHOT_CACHE_DIR.is_symlink() or path.is_symlink():
|
||||
return None
|
||||
with path.open('rb') as stream:
|
||||
raw = stream.read(MAX_SNAPSHOT_BYTES + 1)
|
||||
if len(raw) > MAX_SNAPSHOT_BYTES:
|
||||
return None
|
||||
return _validate_snapshot(json.loads(raw), base, branch, page, requested_head)
|
||||
except (OSError, ValueError, TypeError, KeyError, OverflowError, RecursionError, VersionHistoryError):
|
||||
return None
|
||||
|
||||
|
||||
def _prune_history_snapshots():
|
||||
files = []
|
||||
for path in SNAPSHOT_CACHE_DIR.glob('*.json'):
|
||||
if re.fullmatch(r'[0-9a-f]{64}\.json', path.name) is None or path.is_symlink():
|
||||
continue
|
||||
stat = path.stat()
|
||||
if stat.st_size > MAX_SNAPSHOT_BYTES or time.time() - stat.st_mtime > SNAPSHOT_RETENTION:
|
||||
path.unlink()
|
||||
else:
|
||||
files.append((stat.st_mtime, path, stat.st_size))
|
||||
files.sort()
|
||||
total = sum(entry[2] for entry in files)
|
||||
while len(files) > MAX_SNAPSHOT_FILES or total > MAX_SNAPSHOT_TOTAL_BYTES:
|
||||
_, path, size = files.pop(0)
|
||||
path.unlink()
|
||||
total -= size
|
||||
|
||||
|
||||
def _save_history_snapshot(base, branch, page, requested_head, result, saved_at=None):
|
||||
"""Best-effort atomic snapshot. saved_at is the original timezone-aware ISO timestamp."""
|
||||
temporary = None
|
||||
try:
|
||||
if saved_at is None:
|
||||
saved_at = datetime.fromtimestamp(time.time(), timezone.utc).isoformat()
|
||||
data = {'repository': base, 'branch': branch, 'page': page, 'requestedHead': requested_head,
|
||||
'cachedAt': saved_at, 'result': result}
|
||||
_validate_snapshot(data, base, branch, page, requested_head)
|
||||
raw = json.dumps(data, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
|
||||
if len(raw) > MAX_SNAPSHOT_BYTES:
|
||||
return False
|
||||
with _snapshot_lock:
|
||||
if SNAPSHOT_CACHE_DIR.is_symlink():
|
||||
return False
|
||||
SNAPSHOT_CACHE_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
SNAPSHOT_CACHE_DIR.chmod(0o700)
|
||||
fd, temporary = tempfile.mkstemp(prefix='.history-', suffix='.tmp', dir=SNAPSHOT_CACHE_DIR)
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
os.fchmod(stream.fileno(), 0o600)
|
||||
stream.write(raw)
|
||||
stamp = _snapshot_time(saved_at)
|
||||
os.utime(temporary, (stamp, stamp))
|
||||
os.replace(temporary, _snapshot_path(base, branch, page, requested_head))
|
||||
temporary = None
|
||||
_prune_history_snapshots()
|
||||
return True
|
||||
except (OSError, ValueError, TypeError, KeyError, OverflowError, RecursionError, VersionHistoryError):
|
||||
return False
|
||||
finally:
|
||||
if temporary is not None:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def list_versions(repo_path, branch, page=1, head=None):
|
||||
"""Return 25 commits per page; later pages require the first page's head.
|
||||
|
||||
Browsing caches branch heads for up to 60 seconds, then rejects pinned heads
|
||||
removed by a force push. Immutable SHA history is cached for six hours.
|
||||
Transport/quota failures may return a labelled disk snapshot up to 30 days old.
|
||||
Installation uses resolve_version, which always fetches the branch head fresh.
|
||||
"""
|
||||
branch = _branch(repo_path, branch)
|
||||
if type(page) is not int or page < 1:
|
||||
raise VersionHistoryError('Invalid history page; expected a positive integer.')
|
||||
if page > 1 and head is None:
|
||||
raise VersionHistoryError('A pinned history head is required for subsequent pages.')
|
||||
pinned_head = _sha(head, 'history head') if head is not None else None
|
||||
base = _repository(repo_path)
|
||||
try:
|
||||
result = _list_versions(base, branch, page, pinned_head)
|
||||
except HistoryUnavailable as error:
|
||||
snapshot = _load_history_snapshot(base, branch, page, pinned_head)
|
||||
if snapshot is None:
|
||||
raise
|
||||
return dict(snapshot, cached=True, cacheReason=str(error))
|
||||
_save_history_snapshot(base, branch, page, pinned_head, result)
|
||||
return result
|
||||
|
||||
|
||||
def _list_versions(base, branch, page, pinned_head):
|
||||
current = _head(base, branch)
|
||||
if pinned_head is not None:
|
||||
_ancestor(base, pinned_head, current)
|
||||
else:
|
||||
pinned_head = current
|
||||
block_page, offset = divmod((page - 1) * PAGE_SIZE, _BLOCK_SIZE)
|
||||
rows = _block(base, pinned_head, block_page + 1)
|
||||
end = offset + PAGE_SIZE
|
||||
has_more = len(rows) > end
|
||||
if end == _BLOCK_SIZE and len(rows) == _BLOCK_SIZE:
|
||||
has_more = bool(_block(base, pinned_head, block_page + 2))
|
||||
selected = rows[offset:end]
|
||||
if branch.lower() == 'starpilot' and selected:
|
||||
executor = ThreadPoolExecutor(max_workers=_VERSION_WORKERS)
|
||||
try:
|
||||
futures = {executor.submit(_display_version, base, row['sha']): index for index, row in enumerate(selected)}
|
||||
for future in as_completed(futures):
|
||||
index = futures[future]
|
||||
selected[index] = dict(selected[index], version=future.result())
|
||||
finally:
|
||||
# Surface the first failure promptly and cancel queued work. At most four
|
||||
# already-running downloads can finish under their existing HTTP timeout.
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
return {'branch': branch, 'head': pinned_head, 'page': page, 'hasMore': has_more, 'commits': selected}
|
||||
|
||||
|
||||
def resolve_version(repo_path, branch, commit):
|
||||
"""Resolve latest freshly, or prove an exact commit belongs to that branch."""
|
||||
branch = _branch(repo_path, branch)
|
||||
pinned = commit != 'latest'
|
||||
chosen = _sha(commit) if pinned else None
|
||||
base = _repository(repo_path)
|
||||
head = _head(base, branch, fresh=True)
|
||||
if pinned:
|
||||
_ancestor(base, chosen, head)
|
||||
return {'branch': branch, 'commit': chosen if pinned else head, 'head': head, 'pinned': pinned}
|
||||
@@ -0,0 +1,466 @@
|
||||
"""Exact-revision checkout and local recovery. No vehicle-control dependencies.
|
||||
|
||||
A copy of this module is saved outside the checkout as recover.py before reset.
|
||||
Run that copy with --restore while parked if the installed Galaxy lacks history.
|
||||
"""
|
||||
import argparse
|
||||
import ast
|
||||
import inspect
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import stat
|
||||
import tempfile
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
|
||||
|
||||
OS_VERSION_FILE = Path("/VERSION")
|
||||
_ACTIVE_UPDATER = ContextVar("version_install_updater", default=None)
|
||||
|
||||
|
||||
class InstallError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def git(repo, *args, binary=False, timeout=120):
|
||||
result = subprocess.run(['git', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false', '-C', str(repo), *args],
|
||||
capture_output=True, timeout=timeout, env={**os.environ, 'GIT_TERMINAL_PROMPT': '0'})
|
||||
if result.returncode:
|
||||
raise InstallError(result.stderr.decode(errors='replace').strip()[-2000:] or 'Git operation failed')
|
||||
return result.stdout if binary else result.stdout.decode(errors='replace').strip()
|
||||
|
||||
|
||||
def atomic_write(path, data):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600
|
||||
fd, name = tempfile.mkstemp(prefix=path.name + '.version-', dir=path.parent)
|
||||
temporary = Path(name)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
os.fchmod(stream.fileno(), mode)
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
fd = os.open(path.parent, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def require_parked(data_root=Path('/data')):
|
||||
params = Path(data_root) / 'params/d'
|
||||
try:
|
||||
parked = (params / 'IsOnroad').read_bytes() == b'0' and (params / 'IsOffroad').read_bytes() == b'1'
|
||||
except OSError:
|
||||
parked = False
|
||||
if not parked:
|
||||
raise InstallError('A confirmed parked device is required.')
|
||||
|
||||
|
||||
def validate_target(target):
|
||||
if not isinstance(target, dict):
|
||||
raise InstallError('Invalid target')
|
||||
branch, commit = target.get('branch'), target.get('commit')
|
||||
if not isinstance(branch, str) or not branch or branch.startswith('-'):
|
||||
raise InstallError('Invalid target branch')
|
||||
check = subprocess.run(['git', 'check-ref-format', '--branch', branch], capture_output=True)
|
||||
if check.returncode or not isinstance(commit, str) or not re.fullmatch(r'[0-9a-f]{40}', commit):
|
||||
raise InstallError('An exact validated commit and branch are required')
|
||||
|
||||
|
||||
def read_pin(repo, data_root=Path('/data')):
|
||||
try:
|
||||
value = json.loads((Path(data_root) / 'starpilot/version_selection.json').read_text())
|
||||
if isinstance(value, dict) and value.get('commit') == git(repo, 'rev-parse', 'HEAD') and value.get('branch') == git(repo, 'branch', '--show-current'):
|
||||
return value
|
||||
except (OSError, ValueError, InstallError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _agnos_version(text):
|
||||
match = re.search(r'^\s*(?:export\s+)?AGNOS_VERSION=[\"\']?([0-9][0-9A-Za-z._-]*)', text, re.M)
|
||||
if not match:
|
||||
raise InstallError('Unable to determine the selected version\'s AGNOS requirement')
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _selected_firmware_name(app_fn, remote_start, hkg_remote_start, ignore_ignition_line, tesla_wake=False):
|
||||
if not remote_start and not hkg_remote_start and not ignore_ignition_line and not tesla_wake:
|
||||
return app_fn
|
||||
name_parts = ["panda_h7" if app_fn == "panda_h7.bin.signed" else "panda"]
|
||||
if tesla_wake:
|
||||
name_parts.extend(["tesla", "wake"])
|
||||
elif hkg_remote_start:
|
||||
name_parts.extend(["hkg", "remote"])
|
||||
elif remote_start:
|
||||
name_parts.append("remote")
|
||||
if ignore_ignition_line:
|
||||
name_parts.append("can_ignition_only")
|
||||
return "_".join(name_parts) + ".bin.signed"
|
||||
|
||||
|
||||
def _check_firmware_settings(repo, commit, data_root):
|
||||
keys = ('TeslaWakeOnCAN', 'RemoteStartBootsComma', 'HKGRemoteStartBootsComma', 'IgnoreIgnitionLine',
|
||||
'RemoteStart', 'HkgRemoteStart')
|
||||
enabled = {key for key in keys if (Path(data_root) / 'params/d' / key).is_file()
|
||||
and (Path(data_root) / 'params/d' / key).read_bytes() == b'1'}
|
||||
if not enabled:
|
||||
return
|
||||
label = ', '.join(sorted(enabled))
|
||||
if 'TeslaWakeOnCAN' in enabled and enabled & {'RemoteStartBootsComma', 'HKGRemoteStartBootsComma', 'RemoteStart', 'HkgRemoteStart'}:
|
||||
raise InstallError('Tesla wake firmware cannot be combined with remote-start firmware')
|
||||
try:
|
||||
source = git(repo, 'show', commit + ':selfdrive/pandad/panda_firmware.py')
|
||||
# Compare syntax with a reviewed pure selector; never execute selected source.
|
||||
def normalized(node):
|
||||
node.name = 'selector'
|
||||
node.returns = None
|
||||
for arg in node.args.args:
|
||||
arg.annotation = None
|
||||
return ast.dump(node, include_attributes=False)
|
||||
selected = next(node for node in ast.parse(source).body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == 'get_selected_firmware_name')
|
||||
expected = ast.parse(inspect.getsource(_selected_firmware_name)).body[0]
|
||||
reviewed = {normalized(expected)}
|
||||
# Current Dom adds this exact conflict guard to the earlier reviewed selector.
|
||||
# Accept both known trees; never evaluate code from the selected revision.
|
||||
expected.body.insert(0, ast.parse('if tesla_wake and (remote_start or hkg_remote_start):\n raise ValueError("Tesla wake firmware cannot be combined with remote-start firmware")').body[0])
|
||||
reviewed.add(normalized(expected))
|
||||
if normalized(selected) not in reviewed:
|
||||
raise ValueError('unrecognized firmware selection logic')
|
||||
target_keys = git(repo, 'show', commit + ':common/params_keys.h')
|
||||
if any(key not in target_keys for key in enabled):
|
||||
raise ValueError('target does not preserve the enabled parameter')
|
||||
flags = (bool(enabled & {'RemoteStartBootsComma', 'RemoteStart'}),
|
||||
bool(enabled & {'HKGRemoteStartBootsComma', 'HkgRemoteStart'}),
|
||||
'IgnoreIgnitionLine' in enabled, 'TeslaWakeOnCAN' in enabled)
|
||||
for app_fn in ('panda.bin.signed', 'panda_h7.bin.signed'):
|
||||
filename = _selected_firmware_name(app_fn, *flags)
|
||||
if not git(repo, 'show', commit + ':panda/board/obj/' + filename, binary=True):
|
||||
raise ValueError('empty firmware image: ' + filename)
|
||||
except (InstallError, SyntaxError, StopIteration, ValueError) as error:
|
||||
raise InstallError(f'The selected revision cannot preserve enabled firmware settings ({label}): {error}') from error
|
||||
|
||||
|
||||
def preflight(repo, commit, require_device_binaries=True, data_root=Path('/data')):
|
||||
target_version = _agnos_version(git(repo, 'show', commit + ':launch_env.sh'))
|
||||
if require_device_binaries:
|
||||
try:
|
||||
installed_version = OS_VERSION_FILE.read_text().strip()
|
||||
except OSError as error:
|
||||
raise InstallError('Cannot verify the installed AGNOS version') from error
|
||||
if installed_version != target_version:
|
||||
raise InstallError(f'The running device has AGNOS {installed_version}; selected revision requires {target_version}')
|
||||
current_version = _agnos_version((Path(repo) / 'launch_env.sh').read_text())
|
||||
if target_version != current_version:
|
||||
raise InstallError(f'This revision requires AGNOS {target_version}; current software requires {current_version}. OS changes are not supported by historical installation.')
|
||||
manifest = 'system/hardware/tici/agnos.json'
|
||||
try:
|
||||
target_manifest = json.loads(git(repo, 'show', commit + ':' + manifest))
|
||||
current_manifest = json.loads((Path(repo) / manifest).read_text())
|
||||
except (OSError, ValueError) as error:
|
||||
raise InstallError('Cannot verify AGNOS compatibility') from error
|
||||
if target_manifest != current_manifest:
|
||||
raise InstallError('The selected revision has a different AGNOS firmware manifest. Install a revision compatible with the current OS.')
|
||||
# Reject generations which would ignore the persistent update-pause setting.
|
||||
if ('AutomaticUpdates' not in git(repo, 'show', commit + ':common/params_keys.h') or
|
||||
'automatic_updates' not in git(repo, 'show', commit + ':starpilot/common/starpilot_variables.py') or
|
||||
'automatic_updates_enabled' not in git(repo, 'show', commit + ':system/updated/updated.py')):
|
||||
raise InstallError('This revision does not support pausing automatic updates safely')
|
||||
git(repo, 'cat-file', '-e', commit + ':launch_chffrplus.sh')
|
||||
_check_firmware_settings(repo, commit, data_root)
|
||||
if require_device_binaries:
|
||||
for name in ('common/params_pyx.so', 'selfdrive/pandad/pandad', 'system/camerad/camerad'):
|
||||
data = git(repo, 'show', commit + ':' + name, binary=True)
|
||||
if data[:4] != b'\x7fELF' or data[4:6] != b'\x02\x01' or data[18:20] != b'\xb7\x00':
|
||||
raise InstallError(f'The selected revision lacks a compatible ARM64 artifact: {name}')
|
||||
return {'agnos': target_version}
|
||||
|
||||
|
||||
def check_repository_idle(repo):
|
||||
for name in ('index.lock', 'shallow.lock', 'config.lock', 'packed-refs.lock', 'HEAD.lock',
|
||||
'MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD',
|
||||
'rebase-merge', 'rebase-apply', 'sequencer', 'BISECT_LOG'):
|
||||
path = Path(git(repo, 'rev-parse', '--git-path', name))
|
||||
if not path.is_absolute():
|
||||
path = Path(repo) / path
|
||||
if path.exists():
|
||||
if name.endswith('.lock'):
|
||||
raise InstallError(f'Repository is busy ({name}); wait for the existing Git operation to finish')
|
||||
raise InstallError(f'Repository has a Git operation in progress ({name}); finish or abort it before installing or restoring')
|
||||
# Ordinary patches cannot represent the multiple index stages of a conflict.
|
||||
# Check independently of operation markers, which may be missing or stale.
|
||||
if git(repo, 'ls-files', '--unmerged'):
|
||||
raise InstallError('Repository has unmerged index entries; resolve them before installing or restoring')
|
||||
|
||||
|
||||
def _check_submodules(repo):
|
||||
# A superproject patch cannot preserve modified or untracked submodule files.
|
||||
if not (Path(repo) / '.gitmodules').is_file():
|
||||
return
|
||||
if any(line.startswith(('+', 'U')) for line in git(repo, 'submodule', 'status', '--recursive').splitlines()):
|
||||
raise InstallError('A submodule checkout differs from the recorded revision; save it before installing')
|
||||
dirty = git(repo, 'submodule', 'foreach', '--quiet', '--recursive',
|
||||
'git status --porcelain --untracked-files=all --ignore-submodules=none')
|
||||
if dirty:
|
||||
raise InstallError('A submodule has local changes; save them before installing')
|
||||
|
||||
|
||||
def _backup(repo, target, data_root):
|
||||
data_root, repo = Path(data_root), Path(repo).resolve()
|
||||
folder = data_root / 'starpilot/version-backups' / (datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S') + '-' + os.urandom(3).hex())
|
||||
params = data_root / 'params/d'
|
||||
# HEAD -> index and index -> worktree are separate states. A combined HEAD
|
||||
# diff can be empty even when the index contains valuable staged-only edits.
|
||||
index_patch = git(repo, 'diff', '--cached', '--binary', 'HEAD', binary=True)
|
||||
patch = git(repo, 'diff', '--binary', binary=True)
|
||||
files = [name for name in git(repo, 'ls-files', '--others', '--exclude-standard', '-z', binary=True).decode().split('\0') if name]
|
||||
for name in files:
|
||||
if not stat.S_ISREG((repo / name).lstat().st_mode):
|
||||
raise InstallError(f'Untracked recovery entry must be a regular file (no symlinks): {name}')
|
||||
size = len(index_patch) + len(patch) + sum((repo / name).lstat().st_size for name in files)
|
||||
if size > 128 * 1024 * 1024:
|
||||
raise InstallError('Local source changes exceed the recovery backup limit (128 MiB)')
|
||||
if shutil.disk_usage(data_root).free < size + 512 * 1024 * 1024:
|
||||
raise InstallError('At least 512 MiB of free space beyond local changes is required for recovery')
|
||||
folder.mkdir(parents=True, exist_ok=False)
|
||||
atomic_write(folder / 'index.patch', index_patch)
|
||||
atomic_write(folder / 'working.patch', patch)
|
||||
with tarfile.open(folder / 'untracked.tar', 'w') as archive:
|
||||
for name in files:
|
||||
archive.add(repo / name, arcname=name, recursive=False)
|
||||
shutil.copytree(params, folder / 'params', symlinks=False)
|
||||
stats = data_root / 'starpilot/model_stats.sqlite'
|
||||
if stats.is_file():
|
||||
deadline = time.monotonic() + 60
|
||||
def progress(*_):
|
||||
if time.monotonic() > deadline:
|
||||
raise InstallError('Statistics backup timed out; no source files were changed')
|
||||
with sqlite3.connect(stats.as_uri() + '?mode=ro', uri=True, timeout=10) as source, sqlite3.connect(folder / 'model_stats.sqlite') as destination:
|
||||
source.backup(destination, pages=256, progress=progress)
|
||||
old = {'repo': str(repo), 'dataRoot': str(data_root), 'branch': git(repo, 'branch', '--show-current'),
|
||||
'commit': git(repo, 'rev-parse', 'HEAD'), 'target': target, 'createdAt': datetime.now(timezone.utc).isoformat(),
|
||||
'oldPin': read_pin(repo, data_root)}
|
||||
if not old['branch']:
|
||||
raise InstallError('Detached local checkout cannot be recovered by this installer')
|
||||
git(repo, 'update-ref', 'refs/starpilot/version-backups/' + folder.name, old['commit'])
|
||||
atomic_write(folder / 'recovery.json', json.dumps(old, indent=2).encode())
|
||||
shutil.copyfile(__file__, folder / 'recover.py')
|
||||
atomic_write(folder / 'README.txt', b'Park the car, then run: python3 recover.py --restore\nThis returns to the saved source and local edits. Current settings and statistics are kept.\nSaved params and statistics are available for manual recovery; they are not automatically overwritten.\n')
|
||||
os.sync()
|
||||
return folder
|
||||
|
||||
|
||||
def _clear_staging(data_root):
|
||||
# Invalidate the boot-time swap; never delete staged source or drive data.
|
||||
(Path(data_root) / 'safe_staging/finalized/.overlay_consistent').unlink(missing_ok=True)
|
||||
os.sync()
|
||||
|
||||
|
||||
def restore(folder, *, check_parked, restore_data=False):
|
||||
if restore_data:
|
||||
raise InstallError('Settings/statistics restoration is manual to preserve newer data')
|
||||
folder = Path(folder)
|
||||
old = json.loads((folder / 'recovery.json').read_text())
|
||||
repo, data_root = Path(old['repo']), Path(old['dataRoot'])
|
||||
validate_target(old)
|
||||
check_parked()
|
||||
check_repository_idle(repo)
|
||||
# Validate the whole archive before changing the checkout.
|
||||
with tarfile.open(folder / 'untracked.tar') as archive:
|
||||
for member in archive.getmembers():
|
||||
if member.name.startswith('/') or '..' in Path(member.name).parts or not member.isfile():
|
||||
raise InstallError('Unsafe recovery archive entry')
|
||||
atomic_write(data_root / 'params/d/AutomaticUpdates', b'0')
|
||||
updater = _ACTIVE_UPDATER.get()
|
||||
if updater is not None:
|
||||
updater.restart_after_install()
|
||||
git(repo, 'checkout', '--force', '-B', old['branch'], old['commit'])
|
||||
git(repo, 'reset', '--hard', old['commit'])
|
||||
if (repo / '.gitmodules').is_file():
|
||||
git(repo, 'submodule', 'sync', '--recursive')
|
||||
git(repo, 'submodule', 'update', '--init', '--recursive', '--depth=1', timeout=240)
|
||||
# Older backups contain only a combined HEAD -> worktree patch. New backups
|
||||
# first restore staged content and modes, then apply only unstaged changes.
|
||||
index_patch = folder / 'index.patch'
|
||||
if index_patch.is_file() and index_patch.stat().st_size:
|
||||
git(repo, 'apply', '--index', '--binary', str(index_patch))
|
||||
patch = folder / 'working.patch'
|
||||
if patch.stat().st_size:
|
||||
git(repo, 'apply', '--binary', str(patch))
|
||||
# Archive members come from the local Git untracked list. Reject path escape
|
||||
# and links rather than letting a modified recovery archive overwrite /data.
|
||||
with tarfile.open(folder / 'untracked.tar') as archive:
|
||||
for member in archive.getmembers():
|
||||
destination = repo / member.name
|
||||
if member.name.startswith('/') or '..' in Path(member.name).parts or member.issym() or member.islnk():
|
||||
raise InstallError('Unsafe recovery archive entry')
|
||||
if member.isfile():
|
||||
if not destination.resolve().is_relative_to(repo.resolve()):
|
||||
raise InstallError('Recovery destination escapes the checkout')
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.extractfile(member) as source:
|
||||
atomic_write(destination, source.read())
|
||||
destination.chmod(member.mode)
|
||||
pin = data_root / 'starpilot/version_selection.json'
|
||||
if old.get('oldPin'):
|
||||
atomic_write(pin, json.dumps(old['oldPin']).encode())
|
||||
else:
|
||||
pin.unlink(missing_ok=True)
|
||||
_clear_staging(data_root)
|
||||
return old
|
||||
|
||||
|
||||
def install(repo, target, *, data_root=Path('/data'), check_parked, progress, require_device_binaries=True):
|
||||
repo, data_root = Path(repo), Path(data_root)
|
||||
validate_target(target)
|
||||
check_parked()
|
||||
sha = target['commit']
|
||||
if git(repo, 'rev-parse', sha + '^{commit}') != sha:
|
||||
raise InstallError('Fetched revision does not match the selected commit')
|
||||
progress(2, 'Checking compatibility', 100, sha[:10])
|
||||
check_repository_idle(repo)
|
||||
_check_submodules(repo)
|
||||
preflight(repo, sha, require_device_binaries, data_root)
|
||||
check_parked()
|
||||
progress(3, 'Saving recovery backup', 0, 'Preserving local changes, settings and model statistics')
|
||||
backup = _backup(repo, target, data_root)
|
||||
check_parked()
|
||||
atomic_write(data_root / 'params/d/AutomaticUpdates', b'0')
|
||||
updater = _ACTIVE_UPDATER.get()
|
||||
if updater is not None:
|
||||
updater.restart_after_install()
|
||||
_clear_staging(data_root)
|
||||
try:
|
||||
progress(4, 'Installing selected revision', 10, target['branch'] + ' @ ' + sha[:10])
|
||||
git(repo, 'checkout', '--force', '-B', target['branch'], sha)
|
||||
git(repo, 'reset', '--hard', sha)
|
||||
git(repo, 'config', 'branch.' + target['branch'] + '.remote', 'origin')
|
||||
git(repo, 'config', 'branch.' + target['branch'] + '.merge', 'refs/heads/' + target['branch'])
|
||||
if git(repo, 'rev-parse', 'HEAD') != sha:
|
||||
raise InstallError('Installed revision failed verification')
|
||||
modules = repo / '.gitmodules'
|
||||
if modules.is_file() and '[submodule ' in modules.read_text():
|
||||
check_parked()
|
||||
git(repo, 'submodule', 'sync', '--recursive')
|
||||
git(repo, 'submodule', 'update', '--init', '--recursive', '--depth=1', timeout=240)
|
||||
for name in ('.sconsign.dblite', 'cereal/gen'):
|
||||
path = repo / name
|
||||
if path.is_dir() and not path.is_symlink():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink(missing_ok=True)
|
||||
pin = data_root / 'starpilot/version_selection.json'
|
||||
if target['pinned']:
|
||||
atomic_write(pin, json.dumps({'branch': target['branch'], 'commit': sha, 'installedAt': datetime.now(timezone.utc).isoformat(), 'backup': str(backup)}).encode())
|
||||
else:
|
||||
pin.unlink(missing_ok=True)
|
||||
atomic_write(data_root / 'starpilot/last-version-recovery.json', json.dumps({'backup': str(backup)}).encode())
|
||||
updater = _ACTIVE_UPDATER.get()
|
||||
if updater is not None:
|
||||
updater.restart_after_install()
|
||||
progress(4, 'Installing selected revision', 100, 'Exact commit verified; automatic updates paused')
|
||||
return {'backup': str(backup), 'branch': target['branch'], 'commit': sha, 'pinned': target['pinned']}
|
||||
except Exception as error:
|
||||
try:
|
||||
restore(backup, check_parked=check_parked)
|
||||
except Exception as recovery_error:
|
||||
raise InstallError(f'Installation failed: {error}. Recovery required: {backup}/recover.py ({recovery_error})') from error
|
||||
raise InstallError(f'Installation failed; previous source restored: {error}. Backup: {backup}') from error
|
||||
|
||||
|
||||
def _is_updater(argv):
|
||||
return any(arg in (b'system.updated.updated', b'openpilot.system.updated.updated') or
|
||||
arg.endswith(b'/system/updated/updated.py') for arg in argv)
|
||||
|
||||
|
||||
class UpdaterMaintenance:
|
||||
def __init__(self):
|
||||
self.restart = False
|
||||
|
||||
def restart_after_install(self):
|
||||
self.restart = True
|
||||
|
||||
|
||||
def _processes():
|
||||
records = {}
|
||||
for folder in Path('/proc').iterdir():
|
||||
if not folder.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
fields = (folder / 'stat').read_text().rsplit(')', 1)[1].split()
|
||||
records[int(folder.name)] = (int(fields[1]), fields[19], fields[0], (folder / 'cmdline').read_bytes().split(b'\0'))
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
@contextmanager
|
||||
def suspend_updater():
|
||||
"""Suspend only the background updater and its children for maintenance."""
|
||||
stopped = []
|
||||
control = UpdaterMaintenance()
|
||||
token = _ACTIVE_UPDATER.set(control)
|
||||
try:
|
||||
records = _processes()
|
||||
parents = {pid for pid, (_, _, _, argv) in records.items() if _is_updater(argv)}
|
||||
pending = parents
|
||||
while pending:
|
||||
for pid in pending:
|
||||
entry = records.get(pid)
|
||||
if entry and entry[2] not in ('T', 't'):
|
||||
try:
|
||||
os.kill(pid, signal.SIGSTOP)
|
||||
stopped.append((pid, entry[1]))
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
records = _processes()
|
||||
pending = {pid for pid, entry in records.items() if entry[0] in pending}
|
||||
deadline = time.monotonic() + 2
|
||||
while True:
|
||||
records = _processes()
|
||||
if all(pid not in records or records[pid][1] != start or records[pid][2] in ('T', 't', 'Z')
|
||||
for pid, start in stopped):
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
raise InstallError('Updater did not stop; no installation was started')
|
||||
time.sleep(0.01)
|
||||
yield control
|
||||
finally:
|
||||
_ACTIVE_UPDATER.reset(token)
|
||||
records = _processes()
|
||||
for pid, start in reversed(stopped):
|
||||
if pid in records and records[pid][1] == start:
|
||||
try:
|
||||
# A stopped updater caches AutomaticUpdates. After source mutation,
|
||||
# let manager restart it so it reads the persisted pause setting.
|
||||
os.kill(pid, signal.SIGKILL if control.restart else signal.SIGCONT)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--restore', action='store_true', required=True)
|
||||
args = parser.parse_args()
|
||||
saved = Path(__file__).resolve().parent
|
||||
with suspend_updater() as updater:
|
||||
result = restore(saved, check_parked=require_parked)
|
||||
updater.restart_after_install()
|
||||
print('Previous source restored. Settings and statistics retained. Reboot the device when ready.')
|
||||
Reference in New Issue
Block a user