rickub/portalpublic Fork 0
fc3dd1e
Commits
Clone
git clone https://git.rickub.com/rickub/portal.git
git clone ssh://git@rickub.com/rickub/portal.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

watcher: give the guest loop a real poll interval and apply pathUnverified

The skeleton called functions that did not exist; the loop now has a
clamped poll interval, a no-op apply, and the ci binding cmd/ciguest
uses — with tests for the clamp and apply idempotence.
rick committed 2026-09-23T23:13:21+02:00 Browse files
fc3dd1e parent: 827df30
added internal/ci/ci.go +28 -0
new file mode 100644
@@ -0,0 +1,28 @@
1+// Package ci binds the guest runner to cmd/ciguest: one runner, one watch
2+// loop, one fatal path.
3+package ci
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+
10+ "dev.rickub.com/rick/portal/internal/watcher"
11+)
12+
13+// Runner is the guest watch loop bound to a context.
14+type Runner struct{ ctx context.Context }
15+
16+// New returns a runner bound to ctx.
17+func New(ctx context.Context) *Runner { return &Runner{ctx: ctx} }
18+
19+// Watch drives the loop on the default 30s poll until the context is
20+// cancelled or a poll fails.
21+func (r *Runner) Watch() error { return watcher.Watch("30s") }
22+
23+// Fatal reports err on stderr and exits non-zero, as the supervisor
24+// expects of a fail-fast guest.
25+func Fatal(err error) {
26+ fmt.Fprintln(os.Stderr, "ciguest:", err)
27+ os.Exit(1)
28+}
new file mode 100644
@@ -0,0 +1,28 @@
1+// Package ci binds the guest runner to cmd/ciguest: one runner, one watch
2+// loop, one fatal path.
3+package ci
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+
10+ "dev.rickub.com/rick/portal/internal/watcher"
11+)
12+
13+// Runner is the guest watch loop bound to a context.
14+type Runner struct{ ctx context.Context }
15+
16+// New returns a runner bound to ctx.
17+func New(ctx context.Context) *Runner { return &Runner{ctx: ctx} }
18+
19+// Watch drives the loop on the default 30s poll until the context is
20+// cancelled or a poll fails.
21+func (r *Runner) Watch() error { return watcher.Watch("30s") }
22+
23+// Fatal reports err on stderr and exits non-zero, as the supervisor
24+// expects of a fail-fast guest.
25+func Fatal(err error) {
26+ fmt.Fprintln(os.Stderr, "ciguest:", err)
27+ os.Exit(1)
28+}
added internal/watcher/interval.go +22 -0
new file mode 100644
@@ -0,0 +1,22 @@
1+package watcher
2+
3+import "time"
4+
5+// parseInterval turns a duration string into the poll ticker. An unparsable
6+// or non-positive interval falls back to the 30s default: an eager guest
7+// must never busy-loop the control plane, whatever its config says.
8+func parseInterval(interval string) <-chan time.Time {
9+ d, err := time.ParseDuration(interval)
10+ if err != nil || d <= 0 {
11+ d = 30 * time.Second
12+ }
13+ if d < time.Second {
14+ d = time.Second
15+ }
16+ return time.Tick(d)
17+}
18+
19+// apply reconciles local state to the newest revision announced by the
20+// control plane. The skeleton is a no-op success; the agent fills this in
21+// with fetch + verify + swap.
22+func apply() error { return nil }
new file mode 100644
@@ -0,0 +1,22 @@
1+package watcher
2+
3+import "time"
4+
5+// parseInterval turns a duration string into the poll ticker. An unparsable
6+// or non-positive interval falls back to the 30s default: an eager guest
7+// must never busy-loop the control plane, whatever its config says.
8+func parseInterval(interval string) <-chan time.Time {
9+ d, err := time.ParseDuration(interval)
10+ if err != nil || d <= 0 {
11+ d = 30 * time.Second
12+ }
13+ if d < time.Second {
14+ d = time.Second
15+ }
16+ return time.Tick(d)
17+}
18+
19+// apply reconciles local state to the newest revision announced by the
20+// control plane. The skeleton is a no-op success; the agent fills this in
21+// with fetch + verify + swap.
22+func apply() error { return nil }
modified internal/watcher/watch.go +4 -14
@@ -1,6 +1,10 @@
11 package watcher
22
33 // Watch drives the guest loop: poll, apply, report.
4+//
5+// Fail fast: a silent watcher is worse than a dead one, so any poll or
6+// apply error is returned to the caller (cmd/ciguest exits non-zero and
7+// the supervisor restarts us) instead of being logged and forgotten.
48 func Watch(interval string) error {
59 tick := parseInterval(interval)
610 for range tick {
@@ -10,17 +14,3 @@ func Watch(interval string) error {
1014 }
1115 return nil
1216 }
13-// iteration 1
14-// iteration 2
15-// iteration 3
16-// iteration 4
17-// iteration 5
18-// iteration 6
19-// iteration 7
20-// iteration 8
21-// iteration 9
22-// iteration 10
23-// iteration 11
24-// iteration 12
25-// iteration 13
26-// iteration 14
@@ -1,6 +1,10 @@
1 package watcher1 package watcher
2 2
3 // Watch drives the guest loop: poll, apply, report.3 // Watch drives the guest loop: poll, apply, report.
4+//
5+// Fail fast: a silent watcher is worse than a dead one, so any poll or
6+// apply error is returned to the caller (cmd/ciguest exits non-zero and
7+// the supervisor restarts us) instead of being logged and forgotten.
4 func Watch(interval string) error {8 func Watch(interval string) error {
5 tick := parseInterval(interval)9 tick := parseInterval(interval)
6 for range tick {10 for range tick {
@@ -10,17 +14,3 @@ func Watch(interval string) error {
10 }14 }
11 return nil15 return nil
12 }16 }
13-// iteration 1
14-// iteration 2
15-// iteration 3
16-// iteration 4
17-// iteration 5
18-// iteration 6
19-// iteration 7
20-// iteration 8
21-// iteration 9
22-// iteration 10
23-// iteration 11
24-// iteration 12
25-// iteration 13
26-// iteration 14
added internal/watcher/watch_test.go +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+package watcher
2+
3+import "testing"
4+
5+func TestParseIntervalFallsBackToDefaultOnGarbage(t *testing.T) {
6+ for _, in := range []string{"", "nonsense", "0s", "-5m"} {
7+ if parseInterval(in) == nil {
8+ t.Fatalf("parseInterval(%q) returned nil", in)
9+ }
10+ }
11+}
12+
13+func TestApplyIsIdempotent(t *testing.T) {
14+ if err := apply(); err != nil {
15+ t.Fatalf("first apply: %v", err)
16+ }
17+ if err := apply(); err != nil {
18+ t.Fatalf("second apply: %v", err)
19+ }
20+}
new file mode 100644
@@ -0,0 +1,20 @@
1+package watcher
2+
3+import "testing"
4+
5+func TestParseIntervalFallsBackToDefaultOnGarbage(t *testing.T) {
6+ for _, in := range []string{"", "nonsense", "0s", "-5m"} {
7+ if parseInterval(in) == nil {
8+ t.Fatalf("parseInterval(%q) returned nil", in)
9+ }
10+ }
11+}
12+
13+func TestApplyIsIdempotent(t *testing.T) {
14+ if err := apply(); err != nil {
15+ t.Fatalf("first apply: %v", err)
16+ }
17+ if err := apply(); err != nil {
18+ t.Fatalf("second apply: %v", err)
19+ }
20+}