homelab: v0.4.0 — ci/deploy verbs (watch what you trigger)
Adds the verb-group that kills the single biggest reasoning sink in agent sessions — watching a build/deploy to completion (proven the session that built it: hours hand-rolling Woodpecker polling + DB-schema spelunking for one CI incident). - ci status/watch: Woodpecker REST API (version-stable, not its DB schema), reached via the internal Traefik LB (dial 10.0.20.203, SNI=ci.viktorbarzin.me so the cert verifies — the Go form of the house `curl --resolve` pattern), token from WOODPECKER_TOKEN/Vault, repo id resolved from the cwd remote, with retries that ride Woodpecker's intermittent empty responses. watch matches the HEAD/given commit (avoids the post-push race) and exits non-zero on failure. - deploy wait: image-sha match THEN rollout status (rollout status alone returns success on the old ReplicaSet); kubectl-based. - work land now auto-watches CI to green on the landed commit (--no-ci-watch to skip), closing the v0.1 gap. - ci logs deferred to v0.4.1 (Woodpecker detail/log endpoints were the least reliable; status/watch use the working list endpoint). Live-verified ci status/watch against the live API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
787ce4edfa
commit
9189560ac3
10 changed files with 444 additions and 7 deletions
|
|
@ -63,8 +63,8 @@ operations in the encrypted infra repo.
|
|||
|
||||
**`work land` refuses to push when it cannot verify** (no `--verify-cmd` and no
|
||||
auto-detected suite) unless you pass `--no-verify` — landing to master unverified
|
||||
must be deliberate. It does not yet block on CI to green (that arrives with the
|
||||
ci/deploy watch verbs); it reminds you to follow the pipeline.
|
||||
must be deliberate. After pushing it **watches CI to green** (`ci watch` on the
|
||||
landed commit) and fails if the pipeline does; pass `--no-ci-watch` to skip.
|
||||
|
||||
Tiers are recorded per verb so a future PreToolUse classifier can auto-allow
|
||||
reads / prompt writes; v0.1 allows everything and relies on existing gates
|
||||
|
|
@ -94,6 +94,24 @@ the eventual deprecation (rewiring the per-prompt auto-recall + auto-learn hooks
|
|||
to the CLI, then uninstalling the MCP) is a **separate, deliberate follow-up** —
|
||||
see `docs/adr/0008`.
|
||||
|
||||
### v0.4 verbs — ci / deploy
|
||||
|
||||
Watch what you trigger, without hand-rolling Woodpecker/kubectl polling. `ci`
|
||||
talks to the Woodpecker API (token from `WOODPECKER_TOKEN` or Vault
|
||||
`secret/ci/global`) via the internal Traefik LB, resolving the repo from the cwd
|
||||
remote, with retries that ride Woodpecker's intermittent empty responses.
|
||||
|
||||
| Command | Tier | What it does |
|
||||
|---|---|---|
|
||||
| `ci status [commit]` | read | pipeline status for HEAD (or a commit) |
|
||||
| `ci watch [commit]` | read | poll the pipeline to terminal; exit non-zero on failure |
|
||||
| `deploy wait <ns>/<deploy> [--sha SHA]` | read | wait for the deployment image to match the sha, *then* rollout status (rollout status alone lies on the old ReplicaSet) |
|
||||
|
||||
`work land` now calls `ci watch` on the landed commit automatically (skip with
|
||||
`--no-ci-watch`), closing the v0.1 "doesn't wait for CI" gap. `ci logs` (failing
|
||||
step) is deferred to v0.4.1 — Woodpecker's per-pipeline detail/log endpoints were
|
||||
the least reliable; `status`/`watch` use the list endpoint that works.
|
||||
|
||||
## Build / install
|
||||
|
||||
Built from source to `/usr/local/bin/homelab` during devvm provisioning
|
||||
|
|
@ -113,4 +131,4 @@ original flag-based path unchanged, so the webhook handler is unaffected.
|
|||
|
||||
## Design
|
||||
|
||||
See `infra/docs/adr/0004`–`0008` for the architecture decisions.
|
||||
See `infra/docs/adr/0004`–`0009` for the architecture decisions.
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
v0.3.1
|
||||
v0.4.0
|
||||
|
|
|
|||
99
cli/cmd_ci.go
Normal file
99
cli/cmd_ci.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ciCommands() []Command {
|
||||
return []Command{
|
||||
{Path: []string{"ci", "status"}, Tier: TierRead,
|
||||
Summary: "pipeline status for HEAD/a commit: ci status [commit]", Run: ciStatus},
|
||||
{Path: []string{"ci", "watch"}, Tier: TierRead,
|
||||
Summary: "poll the pipeline for HEAD (or a commit) to terminal; non-zero on failure", Run: ciWatch},
|
||||
}
|
||||
}
|
||||
|
||||
func short(s string) string {
|
||||
if len(s) > 8 {
|
||||
return s[:8]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstLine(s string) string { return strings.SplitN(s, "\n", 2)[0] }
|
||||
|
||||
// currentHEAD returns the full HEAD sha of the cwd repo (empty if not a repo).
|
||||
func currentHEAD() string {
|
||||
cwd, _ := os.Getwd()
|
||||
root, err := gitRepoRoot(cwd)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sha, _ := gitOutput(root, "rev-parse", "HEAD")
|
||||
return sha
|
||||
}
|
||||
|
||||
func ciStatus(args []string) error {
|
||||
commit, _ := firstPositional(args)
|
||||
c, err := newWPClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := c.repoID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := c.findPipeline(id, commit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("#%d %s event=%s %s %s\n", p.Number, p.Status, p.Event, short(p.Commit), firstLine(p.Message))
|
||||
return nil
|
||||
}
|
||||
|
||||
func ciWatch(args []string) error {
|
||||
commit, _ := firstPositional(args)
|
||||
if commit == "" {
|
||||
commit = currentHEAD()
|
||||
}
|
||||
if commit == "" {
|
||||
return fmt.Errorf("no commit given and not in a git repo")
|
||||
}
|
||||
c, err := newWPClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := c.repoID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
timeout := 20 * time.Minute
|
||||
deadline := time.Now().Add(timeout)
|
||||
last := ""
|
||||
for time.Now().Before(deadline) {
|
||||
p, err := c.findPipeline(id, commit)
|
||||
if err != nil {
|
||||
if last != "waiting" {
|
||||
fmt.Fprintf(os.Stderr, "homelab: waiting for pipeline (%s)...\n", short(commit))
|
||||
last = "waiting"
|
||||
}
|
||||
} else {
|
||||
if p.Status != last {
|
||||
fmt.Fprintf(os.Stderr, "homelab: #%d %s\n", p.Number, p.Status)
|
||||
last = p.Status
|
||||
}
|
||||
if isTerminalStatus(p.Status) {
|
||||
fmt.Printf("#%d %s %s\n", p.Number, p.Status, short(commit))
|
||||
if isFailureStatus(p.Status) {
|
||||
return fmt.Errorf("pipeline #%d %s (woodpecker repo, see UI/DB for the failing step)", p.Number, p.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
time.Sleep(15 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("timed out after %s waiting for CI on %s", timeout, short(commit))
|
||||
}
|
||||
51
cli/cmd_deploy.go
Normal file
51
cli/cmd_deploy.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func deployCommands() []Command {
|
||||
return []Command{
|
||||
{Path: []string{"deploy", "wait"}, Tier: TierRead,
|
||||
Summary: "wait for <ns>/<deploy> to roll out the current (or --sha) image: deploy wait <ns>/<deploy> [--sha SHA]", Run: deployWait},
|
||||
}
|
||||
}
|
||||
|
||||
// deployWait closes the "did the NEW code land" gap: rollout status alone returns
|
||||
// success on the OLD ReplicaSet, so we first wait for the deployment image to
|
||||
// reference the expected sha, THEN block on rollout status.
|
||||
func deployWait(args []string) error {
|
||||
target, _ := firstPositional(args)
|
||||
if target == "" || !strings.Contains(target, "/") {
|
||||
return fmt.Errorf("usage: homelab deploy wait <ns>/<deploy> [--sha SHA] [--timeout 10m]")
|
||||
}
|
||||
parts := strings.SplitN(target, "/", 2)
|
||||
ns, deploy := parts[0], parts[1]
|
||||
|
||||
sha := flagValue(args, "--sha")
|
||||
if sha == "" {
|
||||
sha = short(currentHEAD())
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Minute)
|
||||
|
||||
if sha != "" {
|
||||
fmt.Fprintf(os.Stderr, "homelab: waiting for %s/%s image to match %s...\n", ns, deploy, sha)
|
||||
matched := false
|
||||
for time.Now().Before(deadline) {
|
||||
img, _ := kubectlCapture(ns, "get", "deploy", deploy, "-o", "jsonpath={.spec.template.spec.containers[*].image}")
|
||||
if strings.Contains(img, sha) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("timed out: %s/%s image never matched %q", ns, deploy, sha)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "homelab: rollout status %s/%s...\n", ns, deploy)
|
||||
return kubectlStream(ns, "rollout", "status", "deploy/"+deploy, "--timeout=180s")
|
||||
}
|
||||
|
|
@ -104,8 +104,15 @@ func workLand(args []string) error {
|
|||
return landFallback(repoRoot, flags, remote, branch, err)
|
||||
}
|
||||
fmt.Printf("homelab: landed %s -> %s/master.\n", branch, remote)
|
||||
fmt.Println("homelab: CI was triggered by the push — watch it to completion before calling the work done")
|
||||
fmt.Println(" (the ci/deploy watch verbs arrive in a later version; for now follow the pipeline manually).")
|
||||
if containsArg(args, "--no-ci-watch") {
|
||||
fmt.Println("homelab: --no-ci-watch set; not waiting for CI.")
|
||||
return nil
|
||||
}
|
||||
landed, _ := gitOutput(repoRoot, "rev-parse", "HEAD")
|
||||
fmt.Fprintln(os.Stderr, "homelab: watching CI for the landed commit...")
|
||||
if err := ciWatch([]string{landed}); err != nil {
|
||||
return fmt.Errorf("landed, but CI did not go green: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ func buildRegistry() []Command {
|
|||
reg = append(reg, workCommands()...)
|
||||
reg = append(reg, k8sCommands()...)
|
||||
reg = append(reg, memoryCommands()...)
|
||||
reg = append(reg, ciCommands()...)
|
||||
reg = append(reg, deployCommands()...)
|
||||
return reg
|
||||
}
|
||||
|
||||
|
|
|
|||
191
cli/woodpecker.go
Normal file
191
cli/woodpecker.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Woodpecker is reached at ci.viktorbarzin.me but routed via the internal Traefik
|
||||
// LB (mirrors the proven `curl --resolve ci.viktorbarzin.me:443:10.0.20.203`):
|
||||
// we dial the LB IP while keeping SNI/Host = the hostname so the cert verifies.
|
||||
const (
|
||||
wpHost = "ci.viktorbarzin.me"
|
||||
wpLBIP = "10.0.20.203"
|
||||
)
|
||||
|
||||
type wpClient struct {
|
||||
base string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// wpToken reads WOODPECKER_TOKEN, else the canonical Vault path.
|
||||
func wpToken() string {
|
||||
if t := firstEnv("WOODPECKER_TOKEN", "WP_TOKEN"); t != "" {
|
||||
return t
|
||||
}
|
||||
out, err := exec.Command("vault", "kv", "get", "-field=woodpecker_api_token", "secret/ci/global").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func newWPClient() (*wpClient, error) {
|
||||
tok := wpToken()
|
||||
if tok == "" {
|
||||
return nil, fmt.Errorf("no woodpecker token — set WOODPECKER_TOKEN or `vault login` (reads secret/ci/global)")
|
||||
}
|
||||
ip := firstEnv("HOMELAB_WP_IP")
|
||||
if ip == "" {
|
||||
ip = wpLBIP
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 8 * time.Second}
|
||||
tr := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if strings.HasPrefix(addr, wpHost+":") {
|
||||
addr = ip + addr[strings.LastIndex(addr, ":"):]
|
||||
}
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
},
|
||||
}
|
||||
return &wpClient{base: "https://" + wpHost, token: tok, http: &http.Client{Timeout: 20 * time.Second, Transport: tr}}, nil
|
||||
}
|
||||
|
||||
// getJSON GETs path into v, retrying the transient empty/5xx responses the
|
||||
// Woodpecker API intermittently returns under load.
|
||||
func (c *wpClient) getJSON(path string, v interface{}) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
req, _ := http.NewRequest("GET", c.base+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 500 || len(strings.TrimSpace(string(body))) == 0 {
|
||||
lastErr = fmt.Errorf("woodpecker GET %s -> %d (empty/5xx, retrying)", path, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("woodpecker GET %s -> %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
type wpPipeline struct {
|
||||
Number int `json:"number"`
|
||||
Status string `json:"status"`
|
||||
Event string `json:"event"`
|
||||
Commit string `json:"commit"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (c *wpClient) recentPipelines(repoID, n int) ([]wpPipeline, error) {
|
||||
var ps []wpPipeline
|
||||
err := c.getJSON(fmt.Sprintf("/api/repos/%d/pipelines?per_page=%d", repoID, n), &ps)
|
||||
return ps, err
|
||||
}
|
||||
|
||||
// findPipeline returns the pipeline for commit (prefix match), or the latest when
|
||||
// commit is empty.
|
||||
func (c *wpClient) findPipeline(repoID int, commit string) (wpPipeline, error) {
|
||||
ps, err := c.recentPipelines(repoID, 25)
|
||||
if err != nil {
|
||||
return wpPipeline{}, err
|
||||
}
|
||||
if len(ps) == 0 {
|
||||
return wpPipeline{}, fmt.Errorf("no pipelines for repo %d", repoID)
|
||||
}
|
||||
if commit == "" {
|
||||
return ps[0], nil
|
||||
}
|
||||
for _, p := range ps {
|
||||
if strings.HasPrefix(p.Commit, commit) {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return wpPipeline{}, fmt.Errorf("no pipeline for commit %s in the last %d", commit[:min(8, len(commit))], len(ps))
|
||||
}
|
||||
|
||||
func (c *wpClient) repoID() (int, error) {
|
||||
owner, repo, err := repoOwnerName()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var r struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := c.getJSON("/api/repos/lookup/"+owner+"/"+repo, &r); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if r.ID == 0 {
|
||||
return 0, fmt.Errorf("repo %s/%s not registered in woodpecker", owner, repo)
|
||||
}
|
||||
return r.ID, nil
|
||||
}
|
||||
|
||||
// repoOwnerName derives <owner>/<repo> from the cwd git remote.
|
||||
func repoOwnerName() (string, string, error) {
|
||||
cwd, _ := os.Getwd()
|
||||
root, err := gitRepoRoot(cwd)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("not in a git repository: %w", err)
|
||||
}
|
||||
remote := preferRemote(remotesOrEmpty(root))
|
||||
url, err := gitOutput(root, "remote", "get-url", remote)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return parseOwnerRepo(url)
|
||||
}
|
||||
|
||||
// parseOwnerRepo extracts owner/repo from an https or ssh git remote URL.
|
||||
func parseOwnerRepo(url string) (string, string, error) {
|
||||
u := strings.TrimSuffix(strings.TrimSpace(url), ".git")
|
||||
u = strings.TrimSuffix(u, "/")
|
||||
if i := strings.Index(u, "://"); i >= 0 {
|
||||
u = u[i+3:]
|
||||
}
|
||||
u = strings.ReplaceAll(u, ":", "/") // git@host:owner/repo -> git@host/owner/repo
|
||||
parts := strings.Split(u, "/")
|
||||
if len(parts) < 2 || parts[len(parts)-1] == "" || parts[len(parts)-2] == "" {
|
||||
return "", "", fmt.Errorf("cannot parse owner/repo from remote %q", url)
|
||||
}
|
||||
return parts[len(parts)-2], parts[len(parts)-1], nil
|
||||
}
|
||||
|
||||
func isTerminalStatus(s string) bool {
|
||||
switch s {
|
||||
case "success", "failure", "error", "killed", "declined", "blocked":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isFailureStatus(s string) bool {
|
||||
return s == "failure" || s == "error" || s == "killed" || s == "declined"
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
40
cli/woodpecker_test.go
Normal file
40
cli/woodpecker_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseOwnerRepo(t *testing.T) {
|
||||
cases := []struct{ in, owner, repo string }{
|
||||
{"https://forgejo.viktorbarzin.me/viktor/infra.git", "viktor", "infra"},
|
||||
{"https://forgejo.viktorbarzin.me/viktor/infra", "viktor", "infra"},
|
||||
{"git@github.com:ViktorBarzin/infra.git", "ViktorBarzin", "infra"},
|
||||
{"https://github.com/ViktorBarzin/tripit/", "ViktorBarzin", "tripit"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
o, r, err := parseOwnerRepo(c.in)
|
||||
if err != nil || o != c.owner || r != c.repo {
|
||||
t.Errorf("parseOwnerRepo(%q) = (%q, %q, %v), want (%q, %q)", c.in, o, r, err, c.owner, c.repo)
|
||||
}
|
||||
}
|
||||
if _, _, err := parseOwnerRepo("nonsense"); err == nil {
|
||||
t.Error("expected error for unparseable remote")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusClassification(t *testing.T) {
|
||||
for _, s := range []string{"success", "failure", "error", "killed"} {
|
||||
if !isTerminalStatus(s) {
|
||||
t.Errorf("%q should be terminal", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"running", "pending"} {
|
||||
if isTerminalStatus(s) {
|
||||
t.Errorf("%q should not be terminal", s)
|
||||
}
|
||||
}
|
||||
if !isFailureStatus("failure") || !isFailureStatus("error") {
|
||||
t.Error("failure/error should classify as failure")
|
||||
}
|
||||
if isFailureStatus("success") {
|
||||
t.Error("success must not classify as failure")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue