Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ BACKSTAGE_URL=
BACKSTAGE_TOKEN=
AUDIT_DB_URL=
# workitem/v1 store (/api/v1/work-items*); falls back to AUDIT_DB_URL.
# Any WORK_ITEMS_DISABLED value except false/0/no/off turns it off (routes answer 503).
# Any WORK_ITEMS_DISABLED value except false/f/0/no/off turns it off (routes answer 503).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — this commit touches .env.example to correct the WORK_ITEMS_DISABLED wording, but does not add the five ROADMAP_STATE_* variables it introduces.

README.md gains all five rows (ROADMAP_STATE_REPO_URL, _BRANCH, _LOCAL_PATH, _TOKEN, _DISABLED), and .env.example is the file an operator copies to .env — WORK_ITEMS_DB_URL / WORK_ITEMS_DISABLED are both listed there for exactly that reason. Someone bringing up a local API from the template has no indication that a second git checkout is about to be cloned into /tmp/roadmap-state, nor the one variable that stops it. That matters more for this feature than for most, because the reader starts unconditionally at boot and fails only into log lines.

Five commented lines mirroring the WORK_ITEMS_* block would keep the template and the README saying the same thing.

WORK_ITEMS_DB_URL=
WORK_ITEMS_DISABLED=
LOKI_URL=
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,12 @@ docker run -p 8080:8080 mctl-api
| `AUDIT_DB_URL` | PostgreSQL connection string (falls back to in-memory). `sslmode=disable` is upgraded to `require`, or `verify-full` when a CNPG CA is mounted. | — | No |
| `HUMAN_INPUT_DB_URL` | PostgreSQL for the human-input delivery ledger (`POST /api/v1/human-input/{request_id}/response`). Falls back to `AUDIT_DB_URL`; with neither, that endpoint answers 503 (there is no in-memory fallback). The raw answer is kept only until its delivery resolves, or until the request expires. | — | No |
| `WORK_ITEMS_DB_URL` | PostgreSQL for the `workitem/v1` store (`/api/v1/work-items*`). Falls back to `AUDIT_DB_URL`; with neither, every work-items route answers 503. | — | No |
| `WORK_ITEMS_DISABLED` | Kill switch: any value except `false`/`0`/`no`/`off` leaves the work-items store off (routes answer 503) without touching `AUDIT_DB_URL` for other stores. | unset | No |
| `WORK_ITEMS_DISABLED` | Kill switch: any value except `false`/`f`/`0`/`no`/`off` leaves the work-items store off (routes answer 503) without touching `AUDIT_DB_URL` for other stores. | unset | No |
| `ROADMAP_STATE_REPO_URL` | Repository holding the generated roadmap publication read by `/api/v1/roadmap/*` and the `mctl_get_epic_status` / `mctl_get_ready_work_items` tools. mctl-api only reads it; it never evaluates readiness. | `https://github.com/mctlhq/.github.git` | No |
| `ROADMAP_STATE_BRANCH` | Branch of the publication. | `roadmap-state` | No |
| `ROADMAP_STATE_LOCAL_PATH` | Local checkout, refreshed every 5 minutes. | `/tmp/roadmap-state` | No |
| `ROADMAP_STATE_TOKEN` | Credential for a private publication repository; the default repository is public. | — | No |
| `ROADMAP_STATE_DISABLED` | Kill switch: any value except `false`/`f`/`0`/`no`/`off` leaves the roadmap reader off (routes answer 503). | unset | No |
| `TRUSTED_PROXY_CIDRS` | Comma-separated Traefik CIDRs/IPs trusted for `X-Forwarded-For` on audit events | — | No |
| `ALLOW_INSECURE_DB` | Permit `sslmode=disable` (tests/local only) | — | No |
| `BACKSTAGE_URL` | Backstage catalog URL | — | No |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ package main

import "testing"

func TestWorkItemsDisabledErrsTowardOff(t *testing.T) {
func TestKillSwitchErrsTowardOff(t *testing.T) {
cases := []struct {
value string
want bool
Expand All @@ -26,8 +26,8 @@ func TestWorkItemsDisabledErrsTowardOff(t *testing.T) {
{"true", true}, {"1", true}, {"TRUE", true}, {"yes", true}, {"on", true}, {"disabled", true},
}
for _, c := range cases {
if got := workItemsDisabled(c.value); got != c.want {
t.Errorf("workItemsDisabled(%q) = %v, want %v", c.value, got, c.want)
if got := killSwitchOn(c.value); got != c.want {
t.Errorf("killSwitchOn(%q) = %v, want %v", c.value, got, c.want)
}
}
}
38 changes: 32 additions & 6 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import (
"github.com/mctlhq/mctl-api/internal/loki"
mctlmcp "github.com/mctlhq/mctl-api/internal/mcp"
"github.com/mctlhq/mctl-api/internal/operations"
"github.com/mctlhq/mctl-api/internal/roadmap"
"github.com/mctlhq/mctl-api/internal/temporalclient"
"github.com/mctlhq/mctl-api/internal/usage"
"github.com/mctlhq/mctl-api/internal/vault"
Expand Down Expand Up @@ -241,6 +242,24 @@ func main() {
}
}

// Roadmap read model (mctl-api#333). A second gitops.Reader on the
// generated roadmap-state branch of mctlhq/.github: mctl-api only reads
// the published RoadmapPublication, it never runs the evaluator. The
// repository is public, so no credential is needed; ROADMAP_STATE_TOKEN
// exists for a private fork. ROADMAP_STATE_DISABLED (any value but false/0/no/off) turns it off and
// the roadmap endpoints answer 503.
var roadmapGit *gitops.Reader
var roadmapReader *roadmap.Reader
if !killSwitchOn(os.Getenv("ROADMAP_STATE_DISABLED")) {
roadmapGit = gitops.NewReader(
envOr("ROADMAP_STATE_REPO_URL", "https://github.com/mctlhq/.github.git"),
envOr("ROADMAP_STATE_BRANCH", "roadmap-state"),
envOr("ROADMAP_STATE_LOCAL_PATH", "/tmp/roadmap-state"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the second checkout defaults onto the container's root filesystem, where the first one deliberately does not.

GITOPS_LOCAL_PATH is /data/mctl-gitops in helm/values.yaml, and helm/templates/deployment.yaml:120 backs it with a dedicated emptyDir carrying sizeLimit: 500Mi. /tmp/roadmap-state has neither: it lands on the node's ephemeral storage, which the chart sets no request or limit for, so the only thing bounding it is the node's disk-pressure eviction.

The blast radius is small in practice — refresh() clones --depth=1 --single-branch (reader.go:337) and re-fetches at the same depth, so the checkout stays around a megabyte and does not grow with the branch's history. What is left is the asymmetry: an operator reading the chart sees one checkout with a sized volume and no sign that a second one exists, and repeated fetch --depth=1 on a long-lived pod does accumulate loose objects with no git gc in the loop.

A ROADMAP_STATE_LOCAL_PATH entry under values.yaml's env: map (which already passes through generically, so no template change is needed) pointing at the existing volume, or a second small emptyDir, would make the storage story explicit.

ghtoken.Static(os.Getenv("ROADMAP_STATE_TOKEN")), "", "",
)
roadmapReader = roadmap.NewReader(roadmapGit)
}

// Model usage / cost ledger (mctl-api#266, ADR-012). Optional — enabled
// when USAGE_DB_URL or AUDIT_DB_URL is set. A nil store makes the usage
// endpoints 503; see requireUsageAdmin for why that must not degrade to an
Expand Down Expand Up @@ -314,7 +333,7 @@ func main() {
workItemsDBURL = postgresURL(os.Getenv("AUDIT_DB_URL"))
}
switch {
case workItemsDisabled(os.Getenv("WORK_ITEMS_DISABLED")):
case killSwitchOn(os.Getenv("WORK_ITEMS_DISABLED")):
slog.Warn("WORK_ITEMS_DISABLED is set; /api/v1/work-items routes will return 503")
case workItemsDBURL == "":
slog.Warn("no WORK_ITEMS_DB_URL or AUDIT_DB_URL; /api/v1/work-items routes will return 503")
Expand Down Expand Up @@ -593,6 +612,7 @@ func main() {
AlertStore: alertStore,
AgentRegistry: agentRegistryStore,
Lifecycle: lifecycleStore,
Roadmap: roadmapReader,
Usage: usageStore,
DomainStore: domainStore,
DomainVerifier: domainVerifier,
Expand Down Expand Up @@ -627,6 +647,11 @@ func main() {
ctx, cancel := context.WithCancel(rootCtx)
defer cancel()
go gitReader.RefreshLoop(ctx, 60*time.Second)
if roadmapGit != nil {
// The publication changes at most every couple of hours; freshness is
// the capture time it carries, not how often this pulls.
go roadmapGit.RefreshLoop(ctx, 5*time.Minute)
}

// Close out audit rows the Argo completion webhook cannot reach. The hook's
// HMAC secret exists only in the argo-workflows namespace, so operations
Expand Down Expand Up @@ -1177,11 +1202,12 @@ func splitCSV(v string) []string {
return out
}

// workItemsDisabled reads the WORK_ITEMS_DISABLED kill switch. A kill switch
// errs toward off: any value turns work items off except an explicit
// "false"/"f"/"0"/"no"/"off" (or empty), so a template that renders the flag as
// "false" keeps them on while "yes" or "disabled" never silently fails open.
func workItemsDisabled(v string) bool {
// killSwitchOn reads a kill-switch value (WORK_ITEMS_DISABLED,
// ROADMAP_STATE_DISABLED). It errs toward off: any value except an explicit
// "false"/"f"/"0"/"no"/"off" (or empty) turns the feature off, so a template
// that renders the flag as "false" keeps it on while "yes" or "disabled" never
// silently fails open.
func killSwitchOn(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "false", "f", "0", "no", "off":
return false
Expand Down
10 changes: 10 additions & 0 deletions docs/portal-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@
"name": "mctl_get_agent",
"enabled": false
},
{
"name": "mctl_get_epic_status",
"enabled": false,
"reason": "Read-only roadmap epic status from the published RoadmapPublication (public mctlhq/.github data, no credentials). Not yet enabled on the shared portal: that is an owner decision, not part of mctl-api#333."
},
{
"name": "mctl_get_ready_work_items",
"enabled": false,
"reason": "Read-only list of ready roadmap work items from the published RoadmapPublication (public mctlhq/.github data, no credentials). Not yet enabled on the shared portal: that is an owner decision, not part of mctl-api#333."
},
{
"name": "mctl_get_dev_loop",
"enabled": true,
Expand Down
116 changes: 116 additions & 0 deletions internal/api/handlers_roadmap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright 2025 MCTL Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package api

import (
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"time"

"github.com/mctlhq/mctl-api/internal/roadmap"
)

// Roadmap read model (mctl-api#333). Every answer here is selected from the
// RoadmapPublication that mctlhq/.github publishes to its roadmap-state
// branch; nothing is evaluated, inferred or written. A publication that is
// missing or fails verification answers 503, which means "cannot say", never
// "nothing is ready".

const (
roadmapCodeUnavailable = "roadmap_unavailable"
roadmapCodeEpicNotFound = "epic_not_found"
roadmapCodeInvalid = "invalid_request"
)

func (h *Handlers) roadmapPublication(w http.ResponseWriter) (*roadmap.Publication, bool) {
pub, err := h.opts.Roadmap.Current()
if err != nil {
// The detail (paths, git output) goes to the log; the caller needs
// only to know nothing verified, which means "cannot say".
slog.Warn("roadmap publication unavailable", "error", err)
writeErrorCode(w, http.StatusServiceUnavailable, roadmapCodeUnavailable,
"no verified roadmap publication: readiness is unknown, not empty", nil)
return nil, false
}
return pub, true
}

func writeRoadmapLookupError(w http.ResponseWriter, err error) {
if errors.Is(err, roadmap.ErrEpicNotFound) {
writeErrorCode(w, http.StatusNotFound, roadmapCodeEpicNotFound, err.Error(), nil)
return
}
writeError(w, http.StatusInternalServerError, "roadmap lookup failed")
}

// ListRoadmapEpics handles GET /api/v1/roadmap/epics.
func (h *Handlers) ListRoadmapEpics(w http.ResponseWriter, r *http.Request) {
pub, ok := h.roadmapPublication(w)
if !ok {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"epics": pub.Epics(),
"provenance": pub.Provenance(time.Now()),
})
}

// GetRoadmapEpicStatus handles GET /api/v1/roadmap/epic-status?epic=<name or owner/repo#N>.
func (h *Handlers) GetRoadmapEpicStatus(w http.ResponseWriter, r *http.Request) {
epic := strings.TrimSpace(r.URL.Query().Get("epic"))
if epic == "" {
writeErrorCode(w, http.StatusBadRequest, roadmapCodeInvalid, "epic is required: a name or a root issue such as mctlhq/.github#57", nil)
return
}
pub, ok := h.roadmapPublication(w)
if !ok {
return
}
status, err := pub.EpicStatus(epic, time.Now())
if err != nil {
writeRoadmapLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, status)
}

// GetRoadmapReadyWorkItems handles GET /api/v1/roadmap/ready?epic=&required_only=.
// Without epic it answers for every active epic. required_only defaults to
// true: the safe wave-selection default.
func (h *Handlers) GetRoadmapReadyWorkItems(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
requiredOnly := true
if v := strings.TrimSpace(q.Get("required_only")); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, roadmapCodeInvalid, "required_only must be true or false", nil)
return
}
requiredOnly = b
}
pub, ok := h.roadmapPublication(w)
if !ok {
return
}
ready, err := pub.ReadyWorkItems(q.Get("epic"), requiredOnly, time.Now())
if err != nil {
writeRoadmapLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, ready)
}
Loading
Loading