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{}{
|
||||
|
||||
@@ -868,6 +868,75 @@ func (s *Store) SetGlobalMinControllerVersion(version string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ArtifactManifest is the operator-vouched current artifact set (agent binary + golden archive)
|
||||
// served to the host-bootstrap script so it can verify-before-install. The hub is the TRUST ROOT
|
||||
// for these checksums (a different root than Gitea, which only STORES the bytes): the script fetches
|
||||
// each artifact from Gitea with the config-retrieve git token, then checks its sha256 against the
|
||||
// value recorded here before installing/using it. Empty fields = nothing published yet (the script
|
||||
// then falls back to the local golden / fails clearly on a missing binary).
|
||||
type ArtifactManifest struct {
|
||||
AgentVersion string `json:"agent_version"`
|
||||
AgentSHA256 string `json:"agent_sha256"`
|
||||
GoldenVersion string `json:"golden_version"`
|
||||
GoldenSHA256 string `json:"golden_sha256"`
|
||||
}
|
||||
|
||||
// hub_settings keys for the artifact manifest (BUNDLE slice). Stored as discrete key/value rows in
|
||||
// the existing hub_settings table — same mechanism as the controller-version floor, so it survives
|
||||
// restarts and needs no schema change.
|
||||
const (
|
||||
settingArtifactAgentVersion = "artifact_agent_version"
|
||||
settingArtifactAgentSHA256 = "artifact_agent_sha256"
|
||||
settingArtifactGoldenVersion = "artifact_golden_version"
|
||||
settingArtifactGoldenSHA256 = "artifact_golden_sha256"
|
||||
)
|
||||
|
||||
// getSetting reads a single hub_settings value ("" if the row is absent).
|
||||
func (s *Store) getSetting(key string) string {
|
||||
var v string
|
||||
if err := s.db.QueryRow(`SELECT value FROM hub_settings WHERE key = ?`, key).Scan(&v); err != nil {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// setSetting upserts a single hub_settings value.
|
||||
func (s *Store) setSetting(key, value string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO hub_settings (key, value, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
|
||||
key, value,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetArtifactManifest returns the operator-recorded current artifact set. All-empty when nothing
|
||||
// has been published yet.
|
||||
func (s *Store) GetArtifactManifest() ArtifactManifest {
|
||||
return ArtifactManifest{
|
||||
AgentVersion: s.getSetting(settingArtifactAgentVersion),
|
||||
AgentSHA256: s.getSetting(settingArtifactAgentSHA256),
|
||||
GoldenVersion: s.getSetting(settingArtifactGoldenVersion),
|
||||
GoldenSHA256: s.getSetting(settingArtifactGoldenSHA256),
|
||||
}
|
||||
}
|
||||
|
||||
// SetArtifactManifest persists the operator-recorded current artifact set (all four fields). Each
|
||||
// field is stored independently so a partial form submission (e.g. agent only) still round-trips.
|
||||
func (s *Store) SetArtifactManifest(m ArtifactManifest) error {
|
||||
if err := s.setSetting(settingArtifactAgentVersion, m.AgentVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.setSetting(settingArtifactAgentSHA256, m.AgentSHA256); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.setSetting(settingArtifactGoldenVersion, m.GoldenVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.setSetting(settingArtifactGoldenSHA256, m.GoldenSHA256)
|
||||
}
|
||||
|
||||
// EffectiveMinControllerVersion resolves the floor that actually applies to a customer: the
|
||||
// per-customer override when set (non-empty), otherwise the global floor (hub_settings → config/env
|
||||
// default). Returns "" when no floor applies at all (Phase 2 inert for that customer).
|
||||
|
||||
@@ -139,9 +139,12 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) {
|
||||
return entries[i].CustomerID < entries[j].CustomerID
|
||||
})
|
||||
|
||||
artifacts := s.store.GetArtifactManifest()
|
||||
|
||||
data := struct {
|
||||
Customers []customerListEntry
|
||||
GlobalFloor string
|
||||
Artifacts store.ArtifactManifest
|
||||
ActiveNav string
|
||||
Flash string
|
||||
CSRFToken string
|
||||
@@ -149,6 +152,7 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) {
|
||||
}{
|
||||
Customers: entries,
|
||||
GlobalFloor: globalFloor,
|
||||
Artifacts: artifacts,
|
||||
ActiveNav: "configs",
|
||||
Flash: r.URL.Query().Get("flash"),
|
||||
CSRFToken: s.csrfToken(r),
|
||||
@@ -636,6 +640,58 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/configs?flash=floor_set", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// validSHA256 matches a lowercase 64-hex sha256 digest. Empty is also accepted by the artifact
|
||||
// handler (clears that artifact's checksum).
|
||||
var validSHA256 = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// normalizeSHA256 trims/lowercases and validates a sha256 submitted from the operator UI.
|
||||
// Returns (value, true) on a valid 64-hex digest or empty string; (_, false) otherwise.
|
||||
func normalizeSHA256(raw string) (string, bool) {
|
||||
v := strings.ToLower(strings.TrimSpace(raw))
|
||||
if v == "" {
|
||||
return "", true
|
||||
}
|
||||
if !validSHA256.MatchString(v) {
|
||||
return "", false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// handleSetArtifacts records the operator-vouched current artifact set (agent binary + golden
|
||||
// archive: version + sha256 each) into hub_settings. This is the checksum TRUST ROOT the
|
||||
// host-bootstrap script verifies fetched artifacts against. Versions are validated as bare semver
|
||||
// (reusing the floor validator); sha256s as 64-hex. Empty fields are allowed (clears that field).
|
||||
func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
agentVer, okAV := normalizeFloorInput(r.FormValue("agent_version"))
|
||||
agentSHA, okAS := normalizeSHA256(r.FormValue("agent_sha256"))
|
||||
goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version"))
|
||||
goldenSHA, okGS := normalizeSHA256(r.FormValue("golden_sha256"))
|
||||
if !okAV || !okGV {
|
||||
http.Redirect(w, r, "/configs?flash=artifact_ver_invalid", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if !okAS || !okGS {
|
||||
http.Redirect(w, r, "/configs?flash=artifact_sha_invalid", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetArtifactManifest(store.ArtifactManifest{
|
||||
AgentVersion: agentVer,
|
||||
AgentSHA256: agentSHA,
|
||||
GoldenVersion: goldenVer,
|
||||
GoldenSHA256: goldenSHA,
|
||||
}); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to set artifact manifest: %v", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s", agentVer, goldenVer)
|
||||
http.Redirect(w, r, "/configs?flash=artifacts_set", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor
|
||||
// override. Empty clears the override (the customer then uses the global floor).
|
||||
func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
|
||||
@@ -23,8 +23,9 @@ func TestTemplates_FloorRender(t *testing.T) {
|
||||
|
||||
// configs.html — the list page data shape used by handleConfigList.
|
||||
cfgData := struct {
|
||||
Customers []customerListEntry
|
||||
Customers []customerListEntry
|
||||
GlobalFloor string
|
||||
Artifacts store.ArtifactManifest
|
||||
ActiveNav string
|
||||
Flash string
|
||||
CSRFToken string
|
||||
@@ -35,6 +36,7 @@ func TestTemplates_FloorRender(t *testing.T) {
|
||||
ControllerVersion: "0.86.0", HasConfig: true,
|
||||
}},
|
||||
GlobalFloor: "0.86.0",
|
||||
Artifacts: store.ArtifactManifest{AgentVersion: "0.43.0", GoldenVersion: "0.85.1"},
|
||||
ActiveNav: "configs",
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -268,6 +268,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case path == "/configs/artifacts":
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleSetArtifacts(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/delete")
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{{if eq .Flash "deleted"}}Customer configuration deleted.
|
||||
{{else if eq .Flash "floor_set"}}Controller-version floor saved.
|
||||
{{else if eq .Flash "floor_invalid"}}Invalid version — use X.Y.Z (or blank to clear).
|
||||
{{else if eq .Flash "artifacts_set"}}Artifact manifest saved.
|
||||
{{else if eq .Flash "artifact_ver_invalid"}}Invalid artifact version — use X.Y.Z (or blank to clear).
|
||||
{{else if eq .Flash "artifact_sha_invalid"}}Invalid sha256 — use 64 hex chars (or blank to clear).
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -42,6 +45,31 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- BUNDLE slice: Day-0 artifact manifest (agent binary + golden archive). The hub is the
|
||||
checksum TRUST ROOT — the host-bootstrap script verifies Gitea-fetched artifacts against
|
||||
these sha256s before installing them. Record the version + sha256 printed by
|
||||
publish-agent.sh / build-golden.sh. -->
|
||||
<div class="card" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid #334155; border-radius: 8px;">
|
||||
<h2 style="margin: 0 0 0.5rem;">Day-0 artifacts — agent & golden</h2>
|
||||
<p style="font-size: 0.85em; color: #94a3b8; margin: 0 0 0.5rem;">
|
||||
The current agent binary + golden archive the host-bootstrap script fetches from Gitea and
|
||||
verifies (sha256) before installing. The hub vouches for these checksums (a different trust
|
||||
root than Gitea). Paste the version + sha256 printed by <code>publish-agent.sh</code> /
|
||||
<code>build-golden.sh</code>. Blank a field to clear it.
|
||||
</p>
|
||||
<form method="POST" action="/configs/artifacts" style="display: grid; grid-template-columns: auto 8em 1fr; gap: 0.5rem; align-items: center; max-width: 56em;">
|
||||
{{.CSRFField}}
|
||||
<label style="font-size: 0.9em; color: #cbd5e1;">Agent</label>
|
||||
<input type="text" name="agent_version" value="{{.Artifacts.AgentVersion}}" placeholder="0.43.0" style="padding: 0.3em 0.5em;">
|
||||
<input type="text" name="agent_sha256" value="{{.Artifacts.AgentSHA256}}" placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace;">
|
||||
<label style="font-size: 0.9em; color: #cbd5e1;">Golden</label>
|
||||
<input type="text" name="golden_version" value="{{.Artifacts.GoldenVersion}}" placeholder="0.85.1" style="padding: 0.3em 0.5em;">
|
||||
<input type="text" name="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace;">
|
||||
<span></span><span></span>
|
||||
<button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Customers</h2>
|
||||
<a href="/configs/new" class="btn">+ Add Customer</a>
|
||||
|
||||
Reference in New Issue
Block a user