hub v0.16.0 + host-install v1.1.0: Day-0 artifact manifest + self-install the agent (BUNDLE slice)
Hub (v0.16.0):
- store: ArtifactManifest{agent,golden version+sha256} in hub_settings; Get/SetArtifactManifest.
- handler: GET /api/v1/artifacts/{id} (passphrase auth, mirrors config-retrieve). Unset => 200 empty.
- web: operator UI "Day-0 artifacts" card (POST /configs/artifacts), semver + 64-hex validation.
- artifact_test.go: returned-verbatim / unset-empty / 401 / 404 / store round-trip.
host-install (v1.1.0):
- new step 5/8 agent-install: manifest + git token (config-retrieve) -> fetch binary from Gitea ->
verify sha256 vs hub manifest (abort on mismatch) -> install non-root felhom-agent user + binary +
sudoers (visudo -cf) + canonical unit. Idempotent.
- new step 7/8 golden: local fallback else fetch+verify+import from Gitea (--force-gitea-golden).
- agent now runs non-root (privileged.mode sudo), config chowned to the service user.
- README prerequisites trimmed to: install PVE + create customer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// doArtifacts GETs /artifacts/{id} with the passphrase header (the do() helper only sets Bearer).
|
||||
func doArtifacts(h *Handler, customerID, pw string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/artifacts/"+customerID, nil)
|
||||
if pw != "" {
|
||||
req.Header.Set("X-Retrieval-Password", pw)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// Scenario A — a recorded manifest is returned verbatim.
|
||||
func TestArtifacts_ReturnsRecordedSet(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
||||
if err := st.SetArtifactManifest(store.ArtifactManifest{
|
||||
AgentVersion: "0.43.0",
|
||||
AgentSHA256: strings.Repeat("a", 64),
|
||||
GoldenVersion: "0.85.1",
|
||||
GoldenSHA256: strings.Repeat("b", 64),
|
||||
}); err != nil {
|
||||
t.Fatalf("SetArtifactManifest: %v", err)
|
||||
}
|
||||
|
||||
rr := doArtifacts(h, "c1", "pass-phrase")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got artifactManifestResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Agent.Version != "0.43.0" || got.Agent.SHA256 != strings.Repeat("a", 64) {
|
||||
t.Errorf("agent = %+v", got.Agent)
|
||||
}
|
||||
if got.Golden.Version != "0.85.1" || got.Golden.SHA256 != strings.Repeat("b", 64) {
|
||||
t.Errorf("golden = %+v", got.Golden)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario B — an unset manifest returns 200 with empty fields (NOT an error). The script falls back
|
||||
// to the local golden / fails clearly on a missing binary.
|
||||
func TestArtifacts_EmptyWhenUnset(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
||||
|
||||
rr := doArtifacts(h, "c1", "pass-phrase")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got artifactManifestResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Agent.Version != "" || got.Agent.SHA256 != "" || got.Golden.Version != "" || got.Golden.SHA256 != "" {
|
||||
t.Errorf("expected all-empty manifest, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C — wrong passphrase is 401 (auth mirrors config-retrieve).
|
||||
func TestArtifacts_WrongPassphrase(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
||||
|
||||
rr := doArtifacts(h, "c1", "wrong")
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario D — missing passphrase header is 401.
|
||||
func TestArtifacts_MissingPassphrase(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
||||
|
||||
rr := doArtifacts(h, "c1", "")
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario E — unknown customer is 404 (before any auth comparison leaks).
|
||||
func TestArtifacts_UnknownCustomer(t *testing.T) {
|
||||
h, _, _ := newTestHandler(t)
|
||||
|
||||
rr := doArtifacts(h, "nope", "whatever")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario F — store round-trip: a partial set (agent only) persists independently.
|
||||
func TestArtifacts_PartialSetRoundTrips(t *testing.T) {
|
||||
_, st, _ := newTestHandler(t)
|
||||
if err := st.SetArtifactManifest(store.ArtifactManifest{AgentVersion: "0.43.0", AgentSHA256: strings.Repeat("c", 64)}); err != nil {
|
||||
t.Fatalf("SetArtifactManifest: %v", err)
|
||||
}
|
||||
m := st.GetArtifactManifest()
|
||||
if m.AgentVersion != "0.43.0" || m.AgentSHA256 != strings.Repeat("c", 64) {
|
||||
t.Errorf("agent fields not round-tripped: %+v", m)
|
||||
}
|
||||
if m.GoldenVersion != "" || m.GoldenSHA256 != "" {
|
||||
t.Errorf("golden fields should be empty: %+v", m)
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
|
||||
customerID := strings.TrimPrefix(path, "/config/")
|
||||
h.handleConfigRetrieve(w, r, customerID)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/artifacts/"):
|
||||
customerID := strings.TrimPrefix(path, "/artifacts/")
|
||||
h.handleArtifactManifest(w, r, customerID)
|
||||
case r.Method == http.MethodGet && path == "/assets/manifest":
|
||||
h.handleAssetsManifest(w, r)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/assets/file/"):
|
||||
@@ -1497,6 +1500,64 @@ func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, c
|
||||
w.Write([]byte(yamlOutput))
|
||||
}
|
||||
|
||||
// artifactManifestResponse is the wire shape the host-bootstrap script consumes: the operator-vouched
|
||||
// current agent binary + golden archive (version + sha256 each). The script fetches each artifact from
|
||||
// Gitea with the config-retrieve git token and verifies its sha256 against THESE values before install
|
||||
// — so the hub is the checksum trust root, a different root than Gitea (which only stores the bytes).
|
||||
type artifactManifestResponse struct {
|
||||
Agent artifactEntry `json:"agent"`
|
||||
Golden artifactEntry `json:"golden"`
|
||||
}
|
||||
|
||||
type artifactEntry struct {
|
||||
Version string `json:"version"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// handleArtifactManifest serves the current artifact set for a customer. Auth mirrors
|
||||
// handleConfigRetrieve EXACTLY (X-Retrieval-Password header, 404-then-401 order, constant-time
|
||||
// compare) so a script that can pull the controller.yaml can pull the manifest with the same secret.
|
||||
// v0.16.0 returns the GLOBAL current set for every customer (per-customer pinning is a future hook).
|
||||
// An unset manifest returns empty fields (not an error) — the script falls back to the local golden
|
||||
// and fails clearly on a missing binary.
|
||||
func (h *Handler) handleArtifactManifest(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if customerID == "" {
|
||||
http.Error(w, "Missing customer_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
password := r.Header.Get("X-Retrieval-Password")
|
||||
if password == "" {
|
||||
http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.store.GetCustomerConfig(customerID)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] artifacts: customer lookup failed for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
|
||||
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
m := h.store.GetArtifactManifest()
|
||||
resp := artifactManifestResponse{
|
||||
Agent: artifactEntry{Version: m.AgentVersion, SHA256: m.AgentSHA256},
|
||||
Golden: artifactEntry{Version: m.GoldenVersion, SHA256: m.GoldenSHA256},
|
||||
}
|
||||
h.logger.Printf("[INFO] Artifact manifest served for customer %s (agent=%s golden=%s)", customerID, m.AgentVersion, m.GoldenVersion)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// sendResendEmail sends an email via the Resend HTTP API.
|
||||
func (h *Handler) sendResendEmail(to, subject, textBody string) error {
|
||||
payload := map[string]interface{}{
|
||||
|
||||
Reference in New Issue
Block a user