// The CLI's version-target, tags and releases API (release versioning spec, // 2026-08-08): `carlos version target` and the releases list behind `carlos // releases`. The tags routes have no CLI verb of their own yet — their rows // feed `carlos channels`' tag annotations and the console's releases page, // and a future `carlos tags` verb may read them directly. Every handler // opens with s.apiAccountApp — the SAME account/app 404 apicli.go's other // doors give, so a non-member cannot tell an app they don't belong to apart // from one that does not exist by probing any of these five routes either. package console import ( "context" "errors" "log" "net/http" "os" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // handleAPIVersionTargetGet answers `carlos version target` with no argument: // the app's current target, or an empty one when none has ever been set — // unset is a state, not an error, matching release.GetTarget's own contract. func (s *Server) handleAPIVersionTargetGet(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() vt, _, err := release.GetTarget(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api version-target get %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the version target") return } if vt == nil { writeJSON(w, http.StatusOK, map[string]string{"target": ""}) return } writeJSON(w, http.StatusOK, map[string]any{"target": vt.Target, "set_by": vt.SetBy, "set_at": vt.SetAt}) } // handleAPIVersionTargetSet answers `carlos version target `: the // one write door in this file, gated the same way handleAPIPromote's write // is — apiAccountApp's 404 first, then the shared write budget, keyed on the // caller's address like every other mutation here. // // release.SetTarget carries its own two refusals — not a bare semver, and // not above every existing tag — and this handler surfaces both rather than // re-deriving them: a 400 for the shape (this caller's mistake, fixable by // retyping, checked here before SetTarget is even called) and a 409 for the // ordering (a fact about the app's history that SetTarget's own message // names, recognised below via errors.Is(release.ErrTargetNotAboveTags) // rather than a string match). Anything else SetTarget can return — a // store outage, a lost CAS race against a concurrent writer — is the // STORE's fault, not the caller's: surfacing it as a 409 with the raw // error text would tell a caller retrying a good request that their INPUT // was the problem, and would put storage-layer wording (key names, request // ids) in front of a terminal. Those get logged and answered with the same // fixed 502 every other bucket failure in this file gets. func (s *Server) handleAPIVersionTargetSet(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) // allowWrite picks the person or the service write budget from the // principal (svcprincipal.go) — the same key handleAPIPromote's budget // uses. if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } var req struct { Target string `json:"target"` } if !readJSON(w, r, &req) { return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() target := strings.TrimSpace(req.Target) if _, ok := release.ParseSemVer(target); !ok { jsonError(w, http.StatusBadRequest, "version target: want MAJOR.MINOR.PATCH, like 0.5.0") return } if err := release.SetTarget(ctx, s.cfg.Releases, acct.Sqid, app, target, addr, time.Now().UTC()); err != nil { if errors.Is(err, release.ErrTargetNotAboveTags) || errors.Is(err, release.ErrCASConflict) { // SetTarget's own message names the offending tag when the // target is not above every release already cut — as useful // over the API as it is on the command line, so it passes // through verbatim. A lost CAS race gets the same status: a // concurrent write beat this one, which is the caller's cue to // re-read and retry, exactly like the 409 above. jsonError(w, http.StatusConflict, err.Error()) return } log.Printf("console: api version-target set %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not write the version target") return } s.audit(acct, app, addr, "version-target", "version target set to "+target+" (cli)") writeJSON(w, http.StatusOK, map[string]string{"target": target}) } // tagListRow is one row of the tags list — deliberately WITHOUT Changelog, // which can run to several KB per tag and is not what a list view needs; // GetTag (below) is the door onto a single tag's full changelog. type tagListRow struct { Version string `json:"version"` Release string `json:"release"` TaggedAt time.Time `json:"tagged_at"` Actor string `json:"actor,omitempty"` } // handleAPITagsList answers the tags list: every tag this app has cut, // semver-descending — release.ListTags' own order. `carlos channels` reads // this to annotate its TAG column and the console's releases page reads it // for the same join; there is no standalone `carlos tags` verb yet. func (s *Server) handleAPITagsList(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() tags, err := release.ListTags(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { // ListTags propagates anything beyond a list/delete race (a // corrupted tag object, an unreadable store) rather than swallowing // it into a shorter list — the same "an unreadable row is worth // refusing over, not hiding" call handleAPIChannels' per-row error // makes differently, because there a partial answer is still useful // and here list order already depends on parsing every row. log.Printf("console: api tags list %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the tags") return } out := make([]tagListRow, 0, len(tags)) for _, t := range tags { out = append(out, tagListRow{Version: t.Version, Release: t.Release, TaggedAt: t.TaggedAt, Actor: t.Actor}) } writeJSON(w, http.StatusOK, map[string]any{"tags": out}) } // handleAPITagGet answers a single tag, in full — including the changelog // the list route deliberately omits; the console's releases page renders it. // A version this app never tagged is a 404 naming the version, distinct from // apiAccountApp's byte-identical membership 404 above it: a member is told // exactly what is missing, because by the time this line runs membership is // already proved and there is nothing left to protect by staying vague. func (s *Server) handleAPITagGet(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } version := r.PathValue("version") // Shape-checked before the key builder, not after — the same discipline // apiinstances.go's handleAPIInstanceGet applies to a {host} segment. // A {version} segment arrives percent-decoded, so "..%2Fversion-target" // reaches this handler as a path with a separator in it, and TagKey // would happily join it into a key naming a DIFFERENT object under this // app's prefix. ListTags already only ever produces semver-parsable tag // names (tag.go), so a version that fails ParseSemVer can never name a // real tag regardless of what the store holds — this 404 is correct on // every backend, not just the ones where the traversal itself would fail. if _, ok := release.ParseSemVer(version); !ok { jsonError(w, http.StatusNotFound, "no such tag "+version) return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() tag, err := release.GetTag(ctx, s.cfg.Releases, acct.Sqid, app, version) if err != nil { if errors.Is(err, os.ErrNotExist) { jsonError(w, http.StatusNotFound, "no such tag "+version) return } log.Printf("console: api tag get %s/%s %s: %v", acct.Sqid, app, version, err) jsonError(w, http.StatusBadGateway, "could not read tag "+version) return } writeJSON(w, http.StatusOK, map[string]any{ "version": tag.Version, "release": tag.Release, "commit": tag.Commit, "tagged_at": tag.TaggedAt, "actor": tag.Actor, "changelog": tag.Changelog, }) } // handleAPIReleasesList answers `carlos releases`: every shipped release // (Versions, view.go — the same read the console's own app page uses), // joined against ListTags so a row that a tag points at carries the tag's // name. The join key is Release, not Version-as-tag: a tag names the // ITERATION it was cut from (Tag.Release), so this is the inverse lookup — // "which tag, if any, points at this release". func (s *Server) handleAPIReleasesList(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() versions, err := Versions(s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api releases list %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the releases") return } // Unlike the console's own releases page (handleAppReleases, server.go), // which degrades a broken tags read to a tag-less page, an API caller // that asked for tags and got a wrong 200 back with every tag silently // missing would have no way to tell "no tags exist" from "the read // failed" — so this propagates the error to the same 502 every other // store failure in this file gets. byRelease, err := tagByRelease(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api releases list %s/%s: tags: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the tags") return } out := make([]map[string]any, 0, len(versions)) for _, v := range versions { out = append(out, map[string]any{ "version": v.Version, "kind": v.Kind, "built_at": v.BuiltAt, "label": v.Label, "tag": byRelease[v.Version], }) } writeJSON(w, http.StatusOK, map[string]any{"releases": out}) }