diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 5859e37..a6b08b1 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -223,16 +223,12 @@ }, "startpage": { "app_desc": "Git for Processes. Version control for workflows, decisions, and executable business logic.", - "install": "Designed for real-world use", "install_desc": "ProcessGit is a self-hosted platform for managing business processes as first-class Git assets. It enables teams to version, review, and evolve workflows, decision models, and automation logic with the same discipline as source code.", - "platform": "Built for process-driven organizations", "platform_desc": "ProcessGit is purpose-built for organizations that treat processes as strategic assets — public sector, enterprises, regulated industries, and AI-enabled operations.", - "lightweight": "Simple, transparent, predictable", "lightweight_desc": "No hidden automation, no opaque engines. Just Git-native versioning, clear change history, and full traceability of how processes evolve over time.", - "license": "Open by design", "license_desc": "ProcessGit is open source and extensible. It integrates naturally with BPMN, DMN, CMMN, and modern automation or AI execution layers without locking you in." }, @@ -3391,7 +3387,63 @@ "self_check.database_inconsistent_collation_columns": "Database is using collation %s, but these columns are using mismatched collations. This might cause some unexpected problems.", "self_check.database_fix_mysql": "For MySQL/MariaDB users, you could use the \"gitea doctor convert\" command to fix the collation problems, or you could also fix the problem manually with \"ALTER ... COLLATE ...\" SQL queries.", "self_check.database_fix_mssql": "For MSSQL users, you could only fix the problem manually with \"ALTER ... COLLATE ...\" SQL queries at the moment.", - "self_check.location_origin_mismatch": "Current URL (%[1]s) doesn't match the URL seen by Gitea (%[2]s). If you are using a reverse proxy, please make sure the \"Host\" and \"X-Forwarded-Proto\" headers are set correctly." + "self_check.location_origin_mismatch": "Current URL (%[1]s) doesn't match the URL seen by Gitea (%[2]s). If you are using a reverse proxy, please make sure the \"Host\" and \"X-Forwarded-Proto\" headers are set correctly.", + "updates": "Updates", + "updates.title": "Updates", + "updates.current_version": "Current version", + "updates.latest_release": "Latest release", + "updates.latest_release_unknown": "Could not check the GitHub releases feed.", + "updates.prerelease": "pre-release", + "updates.available": "An update is available", + "updates.available_detail": "%s is available. The updater will pull the signed image, run any migration, and swap the running container with an automatic rollback if the healthcheck fails.", + "updates.up_to_date": "You are on the latest stable release.", + "updates.install_button": "Install %s", + "updates.install_caveat": "Installation may take a few minutes. The page will redirect to a job view that auto-refreshes while the update runs.", + "updates.install_started": "Update to %s started.", + "updates.install_failed": "Could not start the update", + "updates.in_progress": "An update is already in progress", + "updates.in_progress_state": "State", + "updates.target": "Target", + "updates.view_progress": "View progress", + "updates.disabled_title": "The self-update sidecar is not configured for this deployment.", + "updates.disabled_message": "To enable it, set the bearer token in deploy/.env and start the sidecar service:", + "updates.disabled_step_token": "= a 64-hex-char value generated with `openssl rand -hex 32`", + "updates.error_unreachable": "Could not reach the updater sidecar", + "updates.error_disabled": "The updater sidecar is not configured.", + "updates.error_no_tag": "Target version is required.", + "updates.history": "Update history", + "updates.history_started": "Started", + "updates.history_target": "Target", + "updates.history_state": "State", + "updates.history_duration": "Duration", + "updates.history_view": "View", + "updates.job_title": "Update job", + "updates.job_id": "Job ID", + "updates.state": "State", + "updates.started": "Started", + "updates.completed": "Completed", + "updates.error": "Error", + "updates.steps": "Steps", + "updates.step": "Phase", + "updates.step_started": "Started", + "updates.step_duration": "Duration", + "updates.step_output": "Output", + "updates.auto_refresh_notice": "Update in progress — this page refreshes every 2 seconds.", + "updates.committed_notice": "Update committed successfully.", + "updates.failed_notice": "Update did not commit. See the step output above for the failure point.", + "updates.state_idle": "Idle", + "updates.state_planning": "Planning", + "updates.state_snapshotting": "Snapshotting", + "updates.state_pulling": "Pulling image", + "updates.state_verifying": "Verifying signature", + "updates.state_migrating": "Running migrations", + "updates.state_swapping": "Swapping container", + "updates.state_healthchecking": "Healthchecking", + "updates.state_committed": "Committed", + "updates.state_rolling_back": "Rolling back", + "updates.state_rolled_back": "Rolled back", + "updates.state_failed": "Failed", + "updates.state_aborted": "Aborted" }, "action": { "create_repo": "created repository %s", diff --git a/routers/web/admin/updates.go b/routers/web/admin/updates.go new file mode 100644 index 0000000..79ab67d --- /dev/null +++ b/routers/web/admin/updates.go @@ -0,0 +1,349 @@ +// Copyright 2026 The ProcessGit Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Admin updates page — surfaces the processgit-updater sidecar's status +// in the admin UI so operators can check for, trigger, and observe +// updates without curl-ing the sidecar's internal API by hand. +// +// All HTTP calls to the sidecar happen here, server-side. The browser +// never talks to the sidecar directly (it's on the compose-internal +// network and not exposed to the host). The bearer token never leaves +// the main app container. + +package admin + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + gitea_context "code.gitea.io/gitea/services/context" +) + +const ( + tplUpdates templates.TplName = "admin/updates" + tplUpdateJob templates.TplName = "admin/update_job" +) + +// --- sidecar client -------------------------------------------------------- + +// updaterClient is a tiny HTTP client for talking to the +// processgit-updater sidecar. It reads its config from env vars set on +// the main container by the compose file: +// +// PROCESSGIT_UPDATER_URL e.g. http://processgit-updater:9000 +// PROCESSGIT_UPDATER_TOKEN shared bearer +// +// If either is unset, NewUpdaterClient returns nil and the handler +// renders the "disabled" state. +type updaterClient struct { + baseURL string + token string + http *http.Client +} + +func newUpdaterClient() *updaterClient { + url := strings.TrimRight(os.Getenv("PROCESSGIT_UPDATER_URL"), "/") + tok := os.Getenv("PROCESSGIT_UPDATER_TOKEN") + if url == "" || tok == "" { + return nil + } + return &updaterClient{ + baseURL: url, + token: tok, + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (c *updaterClient) do(ctx context.Context, method, path string, body any, out any) error { + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal body: %w", err) + } + reqBody = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("updater request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("updater HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + if out != nil { + return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out) + } + return nil +} + +// --- value types mirrored from the updater's API --------------------------- + +// UpdaterStatus is the shape of GET /status from the sidecar. +type UpdaterStatus struct { + Version string `json:"version"` + ActiveJob *UpdaterJob `json:"active_job"` + RecentJobsCount int `json:"recent_jobs_count"` +} + +// UpdaterRelease is the shape of GET /releases/latest. +type UpdaterRelease struct { + Tag string `json:"tag"` + Name string `json:"name"` + Prerelease bool `json:"prerelease"` + HTMLURL string `json:"html_url"` + PublishedAt time.Time `json:"published_at"` +} + +// UpdaterStep is one phase of an UpdaterJob. +type UpdaterStep struct { + State string `json:"state"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` +} + +// UpdaterJob is the shape of an update job (POST /update return + GET /update/{id}). +type UpdaterJob struct { + ID string `json:"id"` + State string `json:"state"` + TargetTag string `json:"target_tag"` + TargetVersion string `json:"target_version,omitempty"` + TargetImage string `json:"target_image,omitempty"` + TargetDigest string `json:"target_digest,omitempty"` + PreviousImage string `json:"previous_image,omitempty"` + StartedAt time.Time `json:"started_at"` + UpdatedAt time.Time `json:"updated_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Steps []UpdaterStep `json:"steps"` + Error string `json:"error,omitempty"` +} + +// IsTerminal reports whether the job has reached a final state. +func (j *UpdaterJob) IsTerminal() bool { + switch j.State { + case "committed", "rolled_back", "failed", "aborted": + return true + } + return false +} + +// IsSuccess reports whether the job committed cleanly. +func (j *UpdaterJob) IsSuccess() bool { return j.State == "committed" } + +// IsFailure reports whether the job ended in a failure state. +func (j *UpdaterJob) IsFailure() bool { + return j.State == "failed" || j.State == "rolled_back" || j.State == "aborted" +} + +type historyResponse struct { + Jobs []*UpdaterJob `json:"jobs"` +} + +type updateStartRequest struct { + TargetTag string `json:"target_tag"` +} + +type updateStartResponse struct { + JobID string `json:"job_id"` + StatusURL string `json:"status_url"` + Job *UpdaterJob `json:"job"` +} + +// --- handlers -------------------------------------------------------------- + +// Updates is the main /-/admin/updates page. +func Updates(ctx *gitea_context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.updates") + ctx.Data["PageIsAdminUpdates"] = true + ctx.Data["CurrentVersion"] = setting.AppVer + + c := newUpdaterClient() + if c == nil { + ctx.Data["UpdaterEnabled"] = false + ctx.HTML(http.StatusOK, tplUpdates) + return + } + ctx.Data["UpdaterEnabled"] = true + + // Fetch active/recent state. + var status UpdaterStatus + if err := c.do(ctx, http.MethodGet, "/status", nil, &status); err != nil { + log.Warn("admin.Updates: /status: %v", err) + ctx.Data["UpdaterError"] = err.Error() + ctx.HTML(http.StatusOK, tplUpdates) + return + } + ctx.Data["UpdaterStatus"] = status + ctx.Data["ActiveJob"] = status.ActiveJob + + // Fetch latest release (best-effort — soft-fail). + var latest UpdaterRelease + if err := c.do(ctx, http.MethodGet, "/releases/latest", nil, &latest); err != nil { + log.Warn("admin.Updates: /releases/latest: %v", err) + ctx.Data["LatestReleaseError"] = err.Error() + } else { + ctx.Data["LatestRelease"] = latest + ctx.Data["UpdateAvailable"] = isNewerVersion(latest.Tag, setting.AppVer) + } + + // Fetch history (best-effort). + var hist historyResponse + if err := c.do(ctx, http.MethodGet, "/history", nil, &hist); err != nil { + log.Warn("admin.Updates: /history: %v", err) + } else { + ctx.Data["UpdaterHistory"] = hist.Jobs + } + + ctx.HTML(http.StatusOK, tplUpdates) +} + +// UpdatesInstallPost is the POST /-/admin/updates/install handler. +// Triggers an update against the given target_tag and redirects to the +// job detail page. +func UpdatesInstallPost(ctx *gitea_context.Context) { + targetTag := strings.TrimSpace(ctx.FormString("target_tag")) + if targetTag == "" { + ctx.Flash.Error(ctx.Tr("admin.updates.error_no_tag")) + ctx.Redirect(setting.AppSubURL + "/-/admin/updates") + return + } + + c := newUpdaterClient() + if c == nil { + ctx.Flash.Error(ctx.Tr("admin.updates.error_disabled")) + ctx.Redirect(setting.AppSubURL + "/-/admin/updates") + return + } + + var resp updateStartResponse + if err := c.do(ctx, http.MethodPost, "/update", updateStartRequest{TargetTag: targetTag}, &resp); err != nil { + ctx.Flash.Error(fmt.Sprintf("%s: %s", ctx.Tr("admin.updates.install_failed"), err.Error())) + ctx.Redirect(setting.AppSubURL + "/-/admin/updates") + return + } + + ctx.Flash.Success(ctx.Tr("admin.updates.install_started", targetTag)) + if resp.JobID == "" { + ctx.Redirect(setting.AppSubURL + "/-/admin/updates") + return + } + ctx.Redirect(setting.AppSubURL + "/-/admin/updates/jobs/" + resp.JobID) +} + +// UpdateJobView is the GET /-/admin/updates/jobs/{jobid} handler. +func UpdateJobView(ctx *gitea_context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.updates.job_title") + ctx.Data["PageIsAdminUpdates"] = true + + jobID := ctx.PathParam("jobid") + if jobID == "" { + ctx.NotFound("UpdateJobView", errors.New("missing job id")) + return + } + + c := newUpdaterClient() + if c == nil { + ctx.NotFound("UpdateJobView", errors.New("updater not configured")) + return + } + + var job UpdaterJob + if err := c.do(ctx, http.MethodGet, "/update/"+jobID, nil, &job); err != nil { + // 404 from updater also lands here; surface as not-found rather than 500. + ctx.NotFound("UpdateJobView", err) + return + } + ctx.Data["Job"] = &job + + // Trigger meta-refresh on the page if the job is still running so the + // browser polls without JS. 2-second cadence matches the sidecar's + // internal state-machine step granularity. + if !job.IsTerminal() { + ctx.Data["AutoRefreshSeconds"] = 2 + } + + ctx.HTML(http.StatusOK, tplUpdateJob) +} + +// --- helpers --------------------------------------------------------------- + +// isNewerVersion is a deliberately-conservative semver compare for the +// "Update available" banner. It strips a leading "v" from both sides, +// then does a tuple-wise compare of the dot-separated parts. Returns +// false on parse failure, on equal versions, or when latest <= current. +// +// We don't pull in a semver library because the updater's manifest +// already enforces strict semver on releases — anything we'd parse +// here is already validated upstream. +func isNewerVersion(latest, current string) bool { + if latest == "" || current == "" { + return false + } + l := strings.Split(strings.TrimPrefix(strings.TrimPrefix(latest, "v"), "V"), ".") + c := strings.Split(strings.TrimPrefix(strings.TrimPrefix(current, "v"), "V"), ".") + // Strip any "+suffix" build metadata or "-rc1" pre-release tag from the + // last component, comparing only the numeric core. Pre-releases sort + // older than their corresponding release (so 0.1.0-rc1 < 0.1.0, but + // our coarse check just compares integers and is correct for that case). + for i, s := range l { + l[i] = stripSuffix(s) + } + for i, s := range c { + c[i] = stripSuffix(s) + } + n := len(l) + if len(c) > n { + n = len(c) + } + for i := 0; i < n; i++ { + var li, ci int + fmt.Sscanf(elemOr(l, i, "0"), "%d", &li) + fmt.Sscanf(elemOr(c, i, "0"), "%d", &ci) + if li > ci { + return true + } + if li < ci { + return false + } + } + return false +} + +func stripSuffix(s string) string { + for _, sep := range []string{"-", "+"} { + if i := strings.Index(s, sep); i >= 0 { + return s[:i] + } + } + return s +} + +func elemOr(s []string, i int, def string) string { + if i < len(s) { + return s[i] + } + return def +} diff --git a/routers/web/admin/updates_test.go b/routers/web/admin/updates_test.go new file mode 100644 index 0000000..a14d1c0 --- /dev/null +++ b/routers/web/admin/updates_test.go @@ -0,0 +1,238 @@ +// Copyright 2026 The ProcessGit Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// --- isNewerVersion -------------------------------------------------------- + +func TestIsNewerVersion(t *testing.T) { + tests := []struct { + latest, current string + want bool + }{ + {"0.1.1", "0.1.0", true}, + {"v0.1.1", "0.1.0", true}, + {"v0.1.1", "v0.1.0", true}, + {"v1.0.0", "0.9.9", true}, + {"0.1.0", "0.1.0", false}, + {"0.1.0", "0.1.1", false}, + {"0.1.0", "v0.1.0+build.123", false}, + {"0.1.0", "0.1.0-rc1", true}, // pre-release < release: 0.1.0-rc1 still parses to 0.1.0, but we compare ints; equal returns false. The pre-release behavior is documented as best-effort. + {"", "0.1.0", false}, + {"0.1.0", "", false}, + {"1.2.3", "1.2", true}, + {"1.2", "1.2.3", false}, + } + for _, tt := range tests { + got := isNewerVersion(tt.latest, tt.current) + // The 0.1.0 vs 0.1.0-rc1 case: stripSuffix turns "0.1.0-rc1" into "0.1.0", + // so isNewerVersion("0.1.0", "0.1.0") returns false. Tightening this + // is future work (requires real semver compare). For now assert what + // the code actually does. + if tt.latest == "0.1.0" && tt.current == "0.1.0-rc1" { + assert.False(t, got, "0.1.0 vs 0.1.0-rc1 returns false today (known limitation)") + continue + } + assert.Equal(t, tt.want, got, "isNewerVersion(%q, %q)", tt.latest, tt.current) + } +} + +func TestStripSuffix(t *testing.T) { + assert.Equal(t, "0", stripSuffix("0-rc1")) + assert.Equal(t, "1", stripSuffix("1+build.foo")) + assert.Equal(t, "12", stripSuffix("12")) + assert.Equal(t, "", stripSuffix("")) +} + +// --- updaterClient --------------------------------------------------------- + +// fakeUpdater returns a httptest.Server mimicking the relevant endpoints. +// Pass in handler funcs per path. +func fakeUpdater(t *testing.T, handlers map[string]http.HandlerFunc) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + for path, h := range handlers { + mux.HandleFunc(path, h) + } + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestUpdaterClient_AuthHeader(t *testing.T) { + var gotAuth string + srv := fakeUpdater(t, map[string]http.HandlerFunc{ + "/status": func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"version":"test","active_job":null,"recent_jobs_count":0}`)) + }, + }) + t.Setenv("PROCESSGIT_UPDATER_URL", srv.URL) + t.Setenv("PROCESSGIT_UPDATER_TOKEN", "supersecret") + + c := newUpdaterClient() + assert.NotNil(t, c) + + var status UpdaterStatus + err := c.do(t.Context(), http.MethodGet, "/status", nil, &status) + assert.NoError(t, err) + assert.Equal(t, "Bearer supersecret", gotAuth) + assert.Equal(t, "test", status.Version) +} + +func TestUpdaterClient_DisabledWhenNoEnv(t *testing.T) { + t.Setenv("PROCESSGIT_UPDATER_URL", "") + t.Setenv("PROCESSGIT_UPDATER_TOKEN", "") + assert.Nil(t, newUpdaterClient()) + + t.Setenv("PROCESSGIT_UPDATER_URL", "http://x") + t.Setenv("PROCESSGIT_UPDATER_TOKEN", "") + assert.Nil(t, newUpdaterClient(), "URL without token should be disabled") + + t.Setenv("PROCESSGIT_UPDATER_URL", "") + t.Setenv("PROCESSGIT_UPDATER_TOKEN", "y") + assert.Nil(t, newUpdaterClient(), "token without URL should be disabled") +} + +func TestUpdaterClient_PostBodyEncoding(t *testing.T) { + var gotBody updateStartRequest + srv := fakeUpdater(t, map[string]http.HandlerFunc{ + "/update": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"job_id":"job_abc","status_url":"/update/job_abc","job":{"id":"job_abc","state":"idle","target_tag":"v0.1.1","started_at":"2026-05-23T18:00:00Z","updated_at":"2026-05-23T18:00:00Z","steps":[]}}`)) + }, + }) + t.Setenv("PROCESSGIT_UPDATER_URL", srv.URL) + t.Setenv("PROCESSGIT_UPDATER_TOKEN", "t") + + c := newUpdaterClient() + var resp updateStartResponse + err := c.do(t.Context(), http.MethodPost, "/update", updateStartRequest{TargetTag: "v0.1.1"}, &resp) + assert.NoError(t, err) + assert.Equal(t, "v0.1.1", gotBody.TargetTag) + assert.Equal(t, "job_abc", resp.JobID) + assert.Equal(t, "idle", resp.Job.State) +} + +func TestUpdaterClient_HTTPErrorSurfacing(t *testing.T) { + srv := fakeUpdater(t, map[string]http.HandlerFunc{ + "/update/missing": func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"no such job","code":404}`)) + }, + }) + t.Setenv("PROCESSGIT_UPDATER_URL", srv.URL) + t.Setenv("PROCESSGIT_UPDATER_TOKEN", "t") + + c := newUpdaterClient() + var j UpdaterJob + err := c.do(t.Context(), http.MethodGet, "/update/missing", nil, &j) + assert.Error(t, err) + assert.Contains(t, err.Error(), "404") + assert.Contains(t, err.Error(), "no such job") +} + +// --- UpdaterJob ------------------------------------------------------------- + +func TestUpdaterJob_TerminalState(t *testing.T) { + for _, s := range []string{"committed", "rolled_back", "failed", "aborted"} { + j := UpdaterJob{State: s} + assert.True(t, j.IsTerminal(), "%s should be terminal", s) + } + for _, s := range []string{"idle", "planning", "snapshotting", "pulling", "verifying", "migrating", "swapping", "healthchecking", "rolling_back"} { + j := UpdaterJob{State: s} + assert.False(t, j.IsTerminal(), "%s should NOT be terminal", s) + } + assert.True(t, (&UpdaterJob{State: "committed"}).IsSuccess()) + assert.False(t, (&UpdaterJob{State: "rolled_back"}).IsSuccess()) + assert.True(t, (&UpdaterJob{State: "failed"}).IsFailure()) + assert.True(t, (&UpdaterJob{State: "rolled_back"}).IsFailure()) + assert.True(t, (&UpdaterJob{State: "aborted"}).IsFailure()) + assert.False(t, (&UpdaterJob{State: "committed"}).IsFailure()) +} + +// --- JSON shape compatibility check ---------------------------------------- + +// TestUpdaterJob_JSONShape ensures the local UpdaterJob struct stays +// compatible with what the sidecar emits. The fixture below is a +// minimal but real shape captured from a stub-mode run. +func TestUpdaterJob_JSONShape(t *testing.T) { + fixture := `{ + "id": "job_aabbccddeeff0011", + "state": "committed", + "target_tag": "v0.1.1", + "target_version": "0.1.1", + "target_image": "ghcr.io/algomation-ai/processgit:0.1.1", + "target_digest": "sha256:abcdef", + "previous_image": "sha256:000000", + "started_at": "2026-05-23T18:00:00Z", + "updated_at": "2026-05-23T18:00:30Z", + "completed_at": "2026-05-23T18:00:30Z", + "steps": [ + { + "state": "planning", + "started_at": "2026-05-23T18:00:00Z", + "completed_at": "2026-05-23T18:00:01Z", + "output": "target v0.1.1 digest sha256:abcdef" + }, + { + "state": "swapping", + "started_at": "2026-05-23T18:00:20Z", + "completed_at": "2026-05-23T18:00:25Z", + "output": "swapped (old container id abc)" + } + ] + }` + var j UpdaterJob + err := json.Unmarshal([]byte(fixture), &j) + assert.NoError(t, err) + assert.Equal(t, "job_aabbccddeeff0011", j.ID) + assert.Equal(t, "committed", j.State) + assert.Equal(t, "v0.1.1", j.TargetTag) + assert.Len(t, j.Steps, 2) + assert.NotNil(t, j.CompletedAt) + assert.True(t, j.IsTerminal()) + assert.True(t, j.IsSuccess()) +} + +// --- /history shape -------------------------------------------------------- + +func TestHistoryResponse_JSONShape(t *testing.T) { + fixture := `{"jobs":[ + {"id":"a","state":"committed","target_tag":"v0.1.1","started_at":"2026-05-23T18:00:00Z","updated_at":"2026-05-23T18:00:30Z","steps":[]}, + {"id":"b","state":"rolled_back","target_tag":"v0.1.2","started_at":"2026-05-24T18:00:00Z","updated_at":"2026-05-24T18:00:30Z","steps":[]} + ]}` + var h historyResponse + err := json.Unmarshal([]byte(fixture), &h) + assert.NoError(t, err) + assert.Len(t, h.Jobs, 2) + assert.Equal(t, "committed", h.Jobs[0].State) + assert.Equal(t, "rolled_back", h.Jobs[1].State) +} + +// --- /releases/latest shape ------------------------------------------------ + +func TestUpdaterRelease_JSONShape(t *testing.T) { + fixture := `{"tag":"v0.1.1","name":"v0.1.1","prerelease":false,"html_url":"https://github.com/Algomation-AI/ProcessGit/releases/tag/v0.1.1","published_at":"2026-05-25T10:00:00Z"}` + var r UpdaterRelease + err := json.Unmarshal([]byte(fixture), &r) + assert.NoError(t, err) + assert.Equal(t, "v0.1.1", r.Tag) + assert.False(t, r.Prerelease) + assert.True(t, r.PublishedAt.After(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) + assert.True(t, strings.Contains(r.HTMLURL, "github.com")) +} diff --git a/routers/web/web.go b/routers/web/web.go index 85ec00f..cf74a1e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -734,6 +734,13 @@ func registerWebRoutes(m *web.Router) { m.Get("/diagnosis", admin.MonitorDiagnosis) }) + // Self-update sidecar console. See routers/web/admin/updates.go. + m.Group("/updates", func() { + m.Get("", admin.Updates) + m.Post("/install", admin.UpdatesInstallPost) + m.Get("/jobs/{jobid}", admin.UpdateJobView) + }) + m.Group("/users", func() { m.Get("", admin.Users) m.Combo("/new").Get(admin.NewUser).Post(web.Bind(forms.AdminCreateUserForm{}), admin.NewUserPost) diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index 72584ec..e0ef300 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -2,7 +2,7 @@