From 81d394ed50f69b9c1d5a10885f313d2fa171e64e Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:10:45 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(docker):=20batched=20sibling=20inspect?= =?UTF-8?q?=20=E2=80=94=20port=20binds=20+=20image=20labels=20in=20one=20c?= =?UTF-8?q?ommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InspectSiblings fetches, in one remote command, every named container's published port binds (docker inspect's NetworkSettings.Ports: host_ip, host_port, container_port, proto; loopback/tailnet binds kept as-is, exposed-but-unpublished ports yield nothing) and every named image's config labels + repo tags, for the Programme B siblings command. Both halves are read-only inspects. Objects docker cannot find are skipped (the ResolveImageTags tolerance); containers with no binds still get their key, so 'inspect answered' stays distinguishable from 'inspect never mentioned it' — the caller's wholesale-failure tripwire depends on it. ID-form refs (docker ps's 12-hex) resolve to the image's facts by prefix, name/tag refs by their own RepoTags entry. Port binds are sorted deterministically (the port map is a Go map on the wire). --- internal/docker/siblings.go | 233 +++++++++++++++++++++++++++++++ internal/docker/siblings_test.go | 143 +++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 internal/docker/siblings.go create mode 100644 internal/docker/siblings_test.go diff --git a/internal/docker/siblings.go b/internal/docker/siblings.go new file mode 100644 index 0000000..6c21a46 --- /dev/null +++ b/internal/docker/siblings.go @@ -0,0 +1,233 @@ +package docker + +// Second-round facts for sibling discovery (Programme B): every container's +// PUBLISHED port binds and every candidate image's config labels, fetched in +// ONE remote command so `teploy server siblings` costs at most two SSH round +// trips — the first being the shared batched `docker ps` of ListAllContainers +// (whose format already carries container labels). Read-only by construction: +// both reads are inspects. + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "sort" + "strconv" + "strings" + + "github.com/useteploy/teploy/internal/ssh" +) + +// PortBinding is one published host bind of one container port, as docker +// inspect reports it: where the container's listener is reachable from the +// host's networks (0.0.0.0 = every interface, 127.0.0.1 = loopback only, a +// mesh/tailnet IP = that network only). Hints, not endpoints: the consumer +// probes and confirms. +type PortBinding struct { + HostIP string + HostPort int + ContainerPort int + Proto string // "tcp" or "udp" +} + +// ImageFacts is what one image's own metadata says: its config labels (the +// dev.teploy.* and org.opencontainers.image.* families) and its repo tags. +type ImageFacts struct { + ID string + Labels map[string]string + RepoTags []string +} + +// SiblingInspection is the batched second-round read. Ports is keyed by +// container name (docker inspect's .Name, leading slash stripped — the same +// form docker ps's .Names reports). Images is keyed BOTH by the refs the +// caller asked for (a name/tag or a short ID) and by each image's full +// sha256 ID, so a caller holding either form resolves. +type SiblingInspection struct { + Ports map[string][]PortBinding + Images map[string]ImageFacts +} + +// siblingsImageMarker separates the two reads inside one command. Both +// inspect formats start a line with a container name (/name) or an image ID +// (sha256:…), so the marker can never collide with a data line. +const siblingsImageMarker = "__TEPLOY_SIBLINGS_IMAGES__" + +const ( + siblingsPortsLineFormat = `{{.Name}}` + "\t" + `{{json .NetworkSettings.Ports}}` + siblingsImageLineFormat = `{{.Id}}` + "\t" + `{{json .RepoTags}}` + "\t" + `{{json .Config.Labels}}` +) + +// InspectSiblings fetches, in ONE remote command, the published port binds +// for every named container and the labels + repo tags for every named +// image. Objects docker cannot find are skipped (absent from the result), +// the tolerance ResolveImageTags established: a container or image removed +// between the ps read and this one degrades the report, never fails it. A +// wholesale failure (docker down) surfaces as the caller's error — see the +// empty-result tripwire documented on the caller. +func (c *Client) InspectSiblings(ctx context.Context, names, images []string) (*SiblingInspection, error) { + if len(names) == 0 && len(images) == 0 { + return &SiblingInspection{Ports: map[string][]PortBinding{}, Images: map[string]ImageFacts{}}, nil + } + var b strings.Builder + if len(names) > 0 { + b.WriteString("docker inspect --format " + ssh.ShellQuote(siblingsPortsLineFormat)) + for _, n := range names { + b.WriteString(" " + ssh.ShellQuote(n)) + } + b.WriteString(" 2>/dev/null || true") + } + if len(images) > 0 { + if b.Len() > 0 { + b.WriteString("; printf '\\n" + siblingsImageMarker + "\\n'; ") + } + b.WriteString("docker image inspect --format " + ssh.ShellQuote(siblingsImageLineFormat)) + for _, i := range images { + b.WriteString(" " + ssh.ShellQuote(i)) + } + b.WriteString(" 2>/dev/null || true") + } + out, err := c.exec.Run(ctx, b.String()) + if err != nil { + return nil, fmt.Errorf("inspecting sibling facts: %w", err) + } + return parseSiblingInspection(out, names, images) +} + +// parseSiblingInspection splits the command output at the marker and decodes +// both halves. Malformed lines are skipped, not fatal: the surrounding +// command already swallows per-object errors, and one unparseable line must +// not cost the whole observation. +func parseSiblingInspection(output string, names, images []string) (*SiblingInspection, error) { + portsHalf, imagesHalf := output, "" + if i := strings.Index(output, "\n"+siblingsImageMarker+"\n"); i >= 0 { + portsHalf = output[:i] + imagesHalf = output[i+1+len(siblingsImageMarker)+1:] + } else if strings.TrimSpace(output) == siblingsImageMarker { + portsHalf, imagesHalf = "", "" + } else if len(images) > 0 && len(names) == 0 { + imagesHalf = output // ports-less command: everything is the image half + } + + res := &SiblingInspection{Ports: map[string][]PortBinding{}, Images: map[string]ImageFacts{}} + for _, line := range strings.Split(portsHalf, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + rawName, rawPorts, ok := strings.Cut(line, "\t") + if !ok { + continue + } + name := strings.TrimPrefix(rawName, "/") + var portMap map[string][]struct { + HostIp string `json:"HostIp"` + HostPort string `json:"HostPort"` + } + if err := json.Unmarshal([]byte(rawPorts), &portMap); err != nil { + continue + } + for spec, bindings := range portMap { + port, proto, ok := splitPortSpec(spec) + if !ok { + continue + } + for _, b := range bindings { + hostPort, err := strconv.Atoi(b.HostPort) + if err != nil || hostPort <= 0 { + continue + } + res.Ports[name] = append(res.Ports[name], PortBinding{ + HostIP: b.HostIp, HostPort: hostPort, ContainerPort: port, Proto: proto, + }) + } + } + // docker's port map is a Go map on the wire: order it deterministically + // so a pinned observation is reproducible. The container's key is + // created even with no bindings — "inspect answered" and "inspect + // never mentioned it" are different facts (the caller's wholesale- + // failure tripwire depends on the distinction). + if _, ok := res.Ports[name]; !ok { + res.Ports[name] = nil + } + sort.Slice(res.Ports[name], func(i, j int) bool { + a, b := res.Ports[name][i], res.Ports[name][j] + if a.ContainerPort != b.ContainerPort { + return a.ContainerPort < b.ContainerPort + } + if a.HostIP != b.HostIP { + return a.HostIP < b.HostIP + } + if a.HostPort != b.HostPort { + return a.HostPort < b.HostPort + } + return a.Proto < b.Proto + }) + } + + var facts []ImageFacts + for _, line := range strings.Split(imagesHalf, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + id, rest, ok := strings.Cut(line, "\t") + if !ok || !IsImageID(id) { + continue + } + rawTags, rawLabels, _ := strings.Cut(rest, "\t") + var tags []string + if err := json.Unmarshal([]byte(rawTags), &tags); err != nil { + continue + } + var labels map[string]string + if err := json.Unmarshal([]byte(rawLabels), &labels); err != nil { + continue + } + tags = slices.DeleteFunc(tags, func(t string) bool { return t == "" || t == ":" }) + facts = append(facts, ImageFacts{ID: id, Labels: labels, RepoTags: tags}) + } + + // Key by full ID and by every requested ref an image answers to: an + // ID-form ref (docker ps's 12-hex short form) matches by prefix, a + // name/tag ref matches its own RepoTags entry. + for _, f := range facts { + res.Images[f.ID] = f + } + for _, ref := range images { + if _, done := res.Images[ref]; done { + continue + } + for _, f := range facts { + if imageRefMatches(ref, f) { + res.Images[ref] = f + break + } + } + } + return res, nil +} + +// imageRefMatches reports whether docker image inspect's row f is the image +// the caller asked for by ref. +func imageRefMatches(ref string, f ImageFacts) bool { + if IsImageID(ref) { + return strings.HasPrefix(strings.TrimPrefix(f.ID, "sha256:"), strings.TrimPrefix(ref, "sha256:")) + } + return slices.Contains(f.RepoTags, ref) +} + +// splitPortSpec splits docker's port key ("3000/tcp", "53/udp") into port +// and protocol. +func splitPortSpec(spec string) (int, string, bool) { + portStr, proto, ok := strings.Cut(spec, "/") + if !ok || (proto != "tcp" && proto != "udp") { + return 0, "", false + } + port, err := strconv.Atoi(portStr) + if err != nil || port <= 0 { + return 0, "", false + } + return port, proto, true +} diff --git a/internal/docker/siblings_test.go b/internal/docker/siblings_test.go new file mode 100644 index 0000000..921ce43 --- /dev/null +++ b/internal/docker/siblings_test.go @@ -0,0 +1,143 @@ +package docker + +// InspectSiblings (Programme B sibling discovery): the batched second-round +// read — container port binds and image labels in one command, parsed by the +// real decoder over a mock executor. + +import ( + "context" + "testing" + + "github.com/useteploy/teploy/internal/ssh" +) + +func TestInspectSiblings_PortsAndImagesOneCommand(t *testing.T) { + mock := ssh.NewMockExecutor("203.0.113.7", + ssh.MockCommand{Match: "docker inspect --format", Output: "/observe-web-7cf98cae\t" + + `{"3000/tcp":[{"HostIp":"100.108.123.49","HostPort":"3000"},{"HostIp":"::","HostPort":"3000"}],"9229/tcp":null}` + "\n" + + "/forgejo\t" + `{}` + "\n" + + "\n__TEPLOY_SIBLINGS_IMAGES__\n" + + `sha256:1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b` + "\t" + + `["ghcr.io/useteploy/observe:7cf98cae"]` + "\t" + + `{"dev.teploy.product":"observe","org.opencontainers.image.version":"1.4.0"}` + "\n" + + `sha256:9f8e7d6c5b4a9f8e7d6c5b4a9f8e7d6c5b4a9f8e7d6c5b4a9f8e7d6c5b4a9f8e` + "\t" + + `["codeberg.org/forgejo/forgejo:11"]` + "\t" + + `{"org.opencontainers.image.source":"https://codeberg.org/forgejo/forgejo"}` + "\n"}, + ) + got, err := NewClient(mock).InspectSiblings(context.Background(), + []string{"observe-web-7cf98cae", "forgejo"}, + []string{"ghcr.io/useteploy/observe:7cf98cae"}) + if err != nil { + t.Fatalf("InspectSiblings: %v", err) + } + if len(mock.Calls) != 1 { + t.Fatalf("the batched read must be ONE command, got %d: %v", len(mock.Calls), mock.Calls) + } + observe := got.Ports["observe-web-7cf98cae"] + if len(observe) != 2 { + t.Fatalf("observe port binds = %+v, want the tailnet and v6 binds", observe) + } + if observe[0] != (PortBinding{HostIP: "100.108.123.49", HostPort: 3000, ContainerPort: 3000, Proto: "tcp"}) { + t.Errorf("observe first bind = %+v", observe[0]) + } + if len(got.Ports["forgejo"]) != 0 { + t.Errorf("forgejo publishes nothing: %+v", got.Ports["forgejo"]) + } + // The unpublished 9229/tcp (null bindings) yields no hint — only host + // binds count. + for _, b := range observe { + if b.ContainerPort == 9229 { + t.Errorf("an exposed-but-unpublished port is not a hint: %+v", b) + } + } + + byRef, ok := got.Images["ghcr.io/useteploy/observe:7cf98cae"] + if !ok || byRef.Labels["dev.teploy.product"] != "observe" || byRef.Labels["org.opencontainers.image.version"] != "1.4.0" { + t.Errorf("image facts by requested ref = %+v", byRef) + } + byID, ok := got.Images["sha256:1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b"] + if !ok || byID.ID != byRef.ID { + t.Errorf("image facts must also key by full ID: %+v", byID) + } +} + +func TestInspectSiblings_ShortIDRefResolves(t *testing.T) { + mock := ssh.NewMockExecutor("203.0.113.7", + ssh.MockCommand{Match: "docker image inspect", Output: "__TEPLOY_SIBLINGS_IMAGES__\n" + + `sha256:1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b` + "\t" + + `["ghcr.io/useteploy/dash:v0.3.1"]` + "\t" + + `{"dev.teploy.product":"dash"}` + "\n"}, + ) + got, err := NewClient(mock).InspectSiblings(context.Background(), nil, + []string{"1a2b3c4d5e6f"}) + if err != nil { + t.Fatalf("InspectSiblings: %v", err) + } + facts, ok := got.Images["1a2b3c4d5e6f"] + if !ok || facts.Labels["dev.teploy.product"] != "dash" { + t.Fatalf("docker ps's 12-hex image form must resolve to the image's labels: %+v", got.Images) + } + if len(got.Images) != 2 { // the short ref and the full ID + t.Errorf("image keys = %v", keysOf(got.Images)) + } +} + +func TestInspectSiblings_MissingObjectsAreSkipped(t *testing.T) { + // One container vanished between the ps read and the inspect, and one + // image is gone: the found objects still answer. + mock := ssh.NewMockExecutor("203.0.113.7", + ssh.MockCommand{Match: "docker inspect --format", Output: "/caddy\t{}\n" + + "\n__TEPLOY_SIBLINGS_IMAGES__\n"}, + ) + got, err := NewClient(mock).InspectSiblings(context.Background(), + []string{"caddy", "vanished"}, []string{"gone:tag"}) + if err != nil { + t.Fatalf("InspectSiblings: %v", err) + } + if _, ok := got.Ports["vanished"]; ok { + t.Errorf("a vanished container must be absent, not fabricated") + } + if len(got.Ports["caddy"]) != 0 { + t.Errorf("caddy = %+v", got.Ports["caddy"]) + } + if len(got.Images) != 0 { + t.Errorf("a vanished image must be absent: %+v", got.Images) + } +} + +func TestInspectSiblings_NothingAsked(t *testing.T) { + mock := ssh.NewMockExecutor("203.0.113.7") + got, err := NewClient(mock).InspectSiblings(context.Background(), nil, nil) + if err != nil { + t.Fatalf("InspectSiblings with nothing to ask: %v", err) + } + if len(mock.Calls) != 0 { + t.Errorf("no containers means no second round trip: %v", mock.Calls) + } + if got.Ports == nil || got.Images == nil { + t.Errorf("empty maps, not nils: %+v", got) + } +} + +func TestInspectSiblings_UDPAndLoopbackBinds(t *testing.T) { + mock := ssh.NewMockExecutor("203.0.113.7", + ssh.MockCommand{Match: "docker inspect --format", Output: "/dns\t" + + `{"53/udp":[{"HostIp":"127.0.0.1","HostPort":"53"}]}` + "\n"}, + ) + got, err := NewClient(mock).InspectSiblings(context.Background(), []string{"dns"}, nil) + if err != nil { + t.Fatalf("InspectSiblings: %v", err) + } + binds := got.Ports["dns"] + if len(binds) != 1 || binds[0] != (PortBinding{HostIP: "127.0.0.1", HostPort: 53, ContainerPort: 53, Proto: "udp"}) { + t.Errorf("udp loopback bind = %+v", binds) + } +} + +func keysOf(m map[string]ImageFacts) []string { + var out []string + for k := range m { + out = append(out, k) + } + return out +} From 2ec90efdb3e923c990c69f4ca155af24451be046 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:10:53 -0700 Subject: [PATCH 2/4] =?UTF-8?q?feat(siblings):=20teploy=20server=20sibling?= =?UTF-8?q?s=20=E2=80=94=20read-only=20discovery=20of=20teploy=20products?= =?UTF-8?q?=20and=20forgejo=20(siblings-v1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `teploy server siblings [--json]`, addressed like server status/audit (servers.yml name or host, --host/--user/--key override). Programme B's discovery read, plan section 10: candidates from what the containers and their images SAY, never from probing. - product: the container's dev.teploy.product label (source label), else the image's (image_label), else — for the forgejo family, whose upstream images carry no teploy label — the image's org.opencontainers.image. title/source naming forgejo/forgejo or gitea (oci_label). Substring matching is the rule there on purpose: it is a candidate for the consumer to probe (forgejo answers /api/v1/version), never a confirmed identity. - a dev.teploy.product value this CLI does not recognise passes through verbatim under unknown_labeled — a product newer than the CLI stays visible; Dash decides what it knows. - version hints: dev.teploy.version, else the teploy release label when the container belongs to a teploy app, else the image's org.opencontainers.image.version. app: the teploy app label. image: the ps ref, with a 12-hex ID resolved to its first repo tag. - port_hints: the container's published binds — where /.well-known/teploy.json may live. No DNS, no HTTP, no URL guessing, no env, nothing secret-adjacent. At most two remote round trips: the shared batched docker ps (labels ride along) plus one batched inspect for binds + image labels. Read-only pinned by test after TestServerAudit_EveryCommandOnlyReads: no mutating verb or docker subcommand, no stdin, no uploads, <=2 commands. An unlistable server or a wholesale inspect failure is state partial/unreachable with empty arrays — never a clean 'no siblings'. Capability token siblings-v1 (the regenerated mi2.json lands here with the code that added it). --- .../fixtures/version-handshake/valid/mi2.json | 1 + internal/cli/machineinterface.go | 12 +- internal/cli/machineinterface_test.go | 2 +- internal/cli/server.go | 1 + internal/cli/server_siblings.go | 342 ++++++++++++ internal/cli/server_siblings_test.go | 517 ++++++++++++++++++ 6 files changed, 873 insertions(+), 2 deletions(-) create mode 100644 internal/cli/server_siblings.go create mode 100644 internal/cli/server_siblings_test.go diff --git a/contracts/fixtures/version-handshake/valid/mi2.json b/contracts/fixtures/version-handshake/valid/mi2.json index fe7538b..8a99edf 100644 --- a/contracts/fixtures/version-handshake/valid/mi2.json +++ b/contracts/fixtures/version-handshake/valid/mi2.json @@ -36,6 +36,7 @@ "server-rename", "server-status-machine", "server-update", + "siblings-v1", "template-var-stdin", "trigger-convergence-v1", "trigger-poll-v1", diff --git a/internal/cli/machineinterface.go b/internal/cli/machineinterface.go index 3aa1576..67748c9 100644 --- a/internal/cli/machineinterface.go +++ b/internal/cli/machineinterface.go @@ -199,6 +199,15 @@ const ( // `server prune --build-cache --dangling`. keep_versions unset now means 5 // after a notice period. server-audit-envelope pins the JSON. CapServerAuditV1 = "server-audit-v1" + // CapServerSiblingsV1: `teploy server siblings --json` — + // read-only sibling discovery (Programme B): teploy products by the + // dev.teploy.product label (container's own, else its image's) and the + // forgejo family by its OCI image labels, each row with version hints, + // the teploy app name when managed, and published-port hints. No + // probing, no DNS, no env, no credentials: the consumer probes and + // confirms; unrecognised dev.teploy.product values pass through + // verbatim. siblings-envelope pins the JSON. + CapServerSiblingsV1 = "siblings-v1" ) // MachineCapabilities returns every capability token this build @@ -244,7 +253,8 @@ func MachineCapabilities() []string { CapRedeployV1, CapEnvConfigGuardV1, CapDomainsCheckV1, - CapServerAuditV1} + CapServerAuditV1, + CapServerSiblingsV1} sort.Strings(tokens) return tokens } diff --git a/internal/cli/machineinterface_test.go b/internal/cli/machineinterface_test.go index 3bba1f8..25bae82 100644 --- a/internal/cli/machineinterface_test.go +++ b/internal/cli/machineinterface_test.go @@ -106,7 +106,7 @@ func TestCapabilityTokenRegistry(t *testing.T) { "trigger-convergence-v1", } want = append(want, "actor-flag-v1", "events-v1", "config-home-v1", "undeployed-list-v1", "lock-wait-v1", "config-merge-v1", "config-unprivileged-v1", "deploy-home-v1", "backup-target-v1", "backup-json-v1", "backup-fleet-v1") - want = append(want, "secret-stdin-v1", "releases-json-v1", "rollback-json-v1", "redeploy-v1", "env-config-guard-v1", "git-source-v1", "trigger-poll-v1", "server-audit-v1") + want = append(want, "secret-stdin-v1", "releases-json-v1", "rollback-json-v1", "redeploy-v1", "env-config-guard-v1", "git-source-v1", "trigger-poll-v1", "server-audit-v1", "siblings-v1") sort.Strings(want) got := MachineCapabilities() if len(got) != len(want) { diff --git a/internal/cli/server.go b/internal/cli/server.go index 9930a9b..0b368ba 100644 --- a/internal/cli/server.go +++ b/internal/cli/server.go @@ -30,6 +30,7 @@ func newServerCmd(flags *Flags) *cobra.Command { cmd.AddCommand(newServerBackupTargetCmd(flags)) cmd.AddCommand(newServerBackupPolicyCmd(flags)) cmd.AddCommand(newServerAuditCmd(flags)) + cmd.AddCommand(newServerSiblingsCmd(flags)) cmd.AddCommand(newServerPruneCmd(flags)) return cmd diff --git a/internal/cli/server_siblings.go b/internal/cli/server_siblings.go new file mode 100644 index 0000000..b8d9d93 --- /dev/null +++ b/internal/cli/server_siblings.go @@ -0,0 +1,342 @@ +package cli + +// `teploy server siblings `: the Programme B discovery read (plan +// section 10). Lists every CONTAINER-OBSERVED candidate sibling product on a +// server — teploy products by the dev.teploy.product label (their own or +// their image's), the forgejo family by its OCI image labels — with version +// hints and the published ports /.well-known/teploy.json might live behind. +// +// This command only OBSERVES: no DNS, no probing, no URL guessing, and never +// anything secret-adjacent (labels, ports and image refs only — no env, no +// credentials). Dash does the probing and confirming; credential minting and +// env writes need a confirmed instance_id plus admin consent, which is Dash's +// surface, not this one. Every remote command here only reads (a test pins +// that) and the whole observation costs at most two SSH round trips: the +// shared batched `docker ps`, then one batched inspect for port binds and +// image labels. +// +// An unrecognised dev.teploy.product value is passed through verbatim in +// unknown_labeled — a product newer than this CLI stays visible, and Dash +// decides what it knows. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/docker" + "github.com/useteploy/teploy/internal/ssh" +) + +// Label keys sibling detection reads. dev.teploy.* is the product contract +// (plan section 10.1); org.opencontainers.image.* is the OCI label set +// upstream images (forgejo, gitea) already carry. +const ( + labelTeployProduct = "dev.teploy.product" + labelTeployVersion = "dev.teploy.version" + ociLabelTitle = "org.opencontainers.image.title" + ociLabelSource = "org.opencontainers.image.source" + ociLabelVersion = "org.opencontainers.image.version" +) + +// Where a row's product came from. Stable vocabulary: consumers code +// against it (contracts/schema/siblings-envelope.schema.json). +const ( + // the container's own dev.teploy.product label + siblingSourceLabel = "label" + // the image's dev.teploy.product label, when the container lacks it + siblingSourceImageLabel = "image_label" + // the image's org.opencontainers.image.title/source names the forgejo + // family (forgejo/forgejo or gitea) + siblingSourceOCI = "oci_label" +) + +// siblingProducts are the products this CLI recognises on sight: the teploy +// family plus forgejo (recognised through its OCI labels — upstream forgejo +// images do not carry the teploy label). Everything else with a +// dev.teploy.product value passes through verbatim in unknown_labeled. +var siblingProducts = map[string]bool{"dash": true, "observe": true, "ship": true, "forgejo": true} + +const siblingsTimeout = 2 * time.Minute + +// siblingPortHint is one published host bind: a hint where the product's +// /.well-known/teploy.json (or forgejo's /api/v1/version) MAY live. Loopback +// and tailnet/mesh binds are included as-is — a consumer on the same network +// reaches them. +type siblingPortHint struct { + HostIP string `json:"host_ip"` + HostPort int `json:"host_port"` + ContainerPort int `json:"container_port"` + Proto string `json:"proto"` +} + +// siblingDTO is one discovered candidate. Version and app are hints ("" when +// unknown); product and source are the detection verdict; port_hints carries +// every published bind docker reports for the container. +type siblingDTO struct { + Container string `json:"container"` + Product string `json:"product"` + Version string `json:"version,omitempty"` + App string `json:"app,omitempty"` + State string `json:"state"` + Image string `json:"image"` + PortHints []siblingPortHint `json:"port_hints"` + Source string `json:"source"` +} + +// serverSiblingsEnvelope is `server siblings --json` +// (contracts/schema/siblings-envelope.schema.json). State follows the audit +// vocabulary: a server that could not be listed is partial with empty arrays, +// never a clean "no siblings". +type serverSiblingsEnvelope struct { + MachineInterface int `json:"machine_interface"` + Server string `json:"server"` + Host string `json:"host"` + ObservedAt time.Time `json:"observed_at"` + State string `json:"state"` + Error string `json:"error,omitempty"` + Siblings []siblingDTO `json:"siblings"` + UnknownLabeled []siblingDTO `json:"unknown_labeled"` +} + +func newServerSiblingsCmd(flags *Flags) *cobra.Command { + return &cobra.Command{ + Use: "siblings ", + Short: "Discover sibling teploy products and forgejo on a server (read-only)", + Long: `Lists sibling product candidates on a server, as the containers and their +images report them: teploy products (dash, observe, ship) by the +dev.teploy.product label — the container's own, else its image's — and +forgejo by the OCI labels on its image. Each row carries the version hints +the labels provide, the teploy app name when the container is teploy-managed, +and the container's published ports as hints where its discovery endpoint may +live. A dev.teploy.product value this CLI does not recognise is passed +through verbatim under unknown_labeled. + +Changes nothing and probes nothing: no DNS, no HTTP, no URL guessing, no +credentials or env — deciding which candidate is real (probing +/.well-known/teploy.json), and anything that follows from a confirmed +instance, is the caller's job. With --json the command exits 0 whenever the +envelope was produced; read state (ok / partial / unreachable).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runServerSiblings(flags, args[0], cmd.OutOrStdout()) + }, + } +} + +func runServerSiblings(flags *Flags, server string, out io.Writer) error { + host, user, key, err := config.ResolveServer(server, flags.Host, flags.User, flags.Key) + if err != nil { + return fmt.Errorf("resolving server %q: %w", server, err) + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + ctx, stop := context.WithTimeout(ctx, siblingsTimeout) + defer stop() + + now := time.Now().UTC() + var env serverSiblingsEnvelope + dialCtx, dialCancel := context.WithTimeout(ctx, domainsConnectTimeout) + exec, err := ssh.Connect(dialCtx, ssh.ConnectConfig{Host: host, User: user, KeyPath: key}) + dialCancel() + if err != nil { + env = serverSiblingsEnvelope{MachineInterface: MachineInterface, Server: server, Host: host, ObservedAt: now, + State: auditStateUnreachable, Error: strings.Join(strings.Fields(err.Error()), " "), + Siblings: []siblingDTO{}, UnknownLabeled: []siblingDTO{}} + } else { + defer exec.Close() + env = collectServerSiblings(ctx, exec, server, host, now) + } + if flags.JSON { + return json.NewEncoder(out).Encode(env) + } + return printServerSiblings(out, env) +} + +func printServerSiblings(out io.Writer, env serverSiblingsEnvelope) error { + if env.State == auditStateUnreachable { + return fmt.Errorf("%s could not be observed: %s", env.Server, env.Error) + } + if len(env.Siblings) == 0 && len(env.UnknownLabeled) == 0 { + fmt.Fprintf(out, "No sibling products found on %s\n", env.Server) + } else { + fmt.Fprintf(out, "Siblings on %s:\n", env.Server) + for _, s := range env.Siblings { + fmt.Fprintf(out, " %-40s %-8s %-12s %-12s %s\n", s.Container, s.Product, s.Version, s.App, describeSiblingHints(s)) + } + } + for _, s := range env.UnknownLabeled { + fmt.Fprintf(out, " %-40s %-8s (unrecognised dev.teploy.product, passed through)\n", s.Container, s.Product) + } + if env.State == auditStatePartial { + fmt.Fprintf(out, "\nPartial observation: %s\n", env.Error) + } + return nil +} + +func describeSiblingHints(s siblingDTO) string { + var parts []string + for _, h := range s.PortHints { + parts = append(parts, fmt.Sprintf("%s:%d->%d/%s", h.HostIP, h.HostPort, h.ContainerPort, h.Proto)) + } + if len(parts) == 0 { + return "no published ports" + } + return strings.Join(parts, ", ") +} + +// collectServerSiblings performs the observation on one connected server. +// Split from runServerSiblings so the whole read surface is testable against +// a mock executor. +func collectServerSiblings(ctx context.Context, exec ssh.Executor, server, host string, now time.Time) serverSiblingsEnvelope { + env := serverSiblingsEnvelope{MachineInterface: MachineInterface, Server: server, Host: host, ObservedAt: now, + State: auditStateOK, Siblings: []siblingDTO{}, UnknownLabeled: []siblingDTO{}} + client := docker.NewClient(exec) + + containers, err := client.ListAllContainers(ctx) + if err != nil { + // Nothing was observed: partial with empty arrays, never a clean + // "no siblings". + env.State, env.Error = auditStatePartial, err.Error() + return env + } + if len(containers) == 0 { + return env + } + + // The one batched second round: published binds for every container, + // labels for every distinct image (fallback detection needs the images + // of unlabelled containers; version fallback and tag resolution need + // the images of labelled ones — the union is every container's image, + // deduplicated). + sort.Slice(containers, func(i, j int) bool { return containers[i].Name < containers[j].Name }) + names := make([]string, 0, len(containers)) + imageSet := map[string]bool{} + for _, c := range containers { + names = append(names, c.Name) + if c.Image != "" { + imageSet[c.Image] = true + } + } + images := make([]string, 0, len(imageSet)) + for ref := range imageSet { + images = append(images, ref) + } + sort.Strings(images) + + var facts docker.SiblingInspection // zero value: empty maps, nil-safe on a failed second round + if insp, err := client.InspectSiblings(ctx, names, images); err != nil { + env.State, env.Error = auditStatePartial, err.Error() + } else if len(names) > 0 && len(insp.Ports) == 0 { + // Every named container produces an inspect line — even one that + // publishes nothing yields its key with no binds. Zero keys means + // the read failed wholesale (the || true swallowed it), which must + // read as partial, not as "no ports, no image labels, all clean". + env.State, env.Error = auditStatePartial, "docker inspect returned nothing" + } else { + facts = *insp + } + + for _, c := range containers { + imageFacts := facts.Images[c.Image] + product, source, known := siblingProductOf(c, imageFacts) + if !known { + continue + } + row := siblingDTO{ + Container: c.Name, + Product: product, + App: c.Labels["teploy.app"], + State: c.State, + Image: resolveSiblingImage(c.Image, imageFacts), + PortHints: []siblingPortHint{}, + Source: source, + } + if row.Image == "" { + row.Image = imageFacts.ID + } + row.Version = siblingVersionOf(c, imageFacts) + for _, b := range facts.Ports[c.Name] { + row.PortHints = append(row.PortHints, siblingPortHint{ + HostIP: b.HostIP, HostPort: b.HostPort, ContainerPort: b.ContainerPort, Proto: b.Proto, + }) + } + if siblingProducts[product] { + env.Siblings = append(env.Siblings, row) + } else { + env.UnknownLabeled = append(env.UnknownLabeled, row) + } + } + return env +} + +// siblingProductOf decides what product a container is. known is false when +// the container is no sibling candidate at all (no teploy label anywhere and +// not forgejo). A dev.teploy.product value outside the recognised set is +// returned verbatim with known true: forward compatibility — the consumer +// decides what it knows. +func siblingProductOf(c docker.Container, imageFacts docker.ImageFacts) (product, source string, known bool) { + if v := strings.TrimSpace(c.Labels[labelTeployProduct]); v != "" { + return v, siblingSourceLabel, true + } + if v := strings.TrimSpace(imageFacts.Labels[labelTeployProduct]); v != "" { + return v, siblingSourceImageLabel, true + } + if forgejoFromOCILabels(imageFacts.Labels) { + return "forgejo", siblingSourceOCI, true + } + return "", "", false +} + +// forgejoFromOCILabels reports whether an image's OCI labels name the +// forgejo family: upstream forgejo/gitea images carry no teploy label, but +// their org.opencontainers.image.source/title do name the project +// (codeberg.org/forgejo/forgejo, github.com/go-gitea/gitea). Substring +// matching is deliberately the rule here — this is a CANDIDATE for the +// consumer to probe (forgejo answers /api/v1/version), never a confirmed +// identity; a tool merely mentioning gitea in its source label costs one +// failed probe, not a wrong connection. +func forgejoFromOCILabels(labels map[string]string) bool { + for _, v := range []string{labels[ociLabelTitle], labels[ociLabelSource]} { + v = strings.ToLower(v) + if strings.Contains(v, "forgejo/forgejo") || strings.Contains(v, "gitea") { + return true + } + } + return false +} + +// siblingVersionOf picks the version hint, in priority order: the +// container's dev.teploy.version label, the teploy release label when the +// container belongs to a teploy app, then the image's +// org.opencontainers.image.version. "" when nothing says. +func siblingVersionOf(c docker.Container, imageFacts docker.ImageFacts) string { + if v := strings.TrimSpace(c.Labels[labelTeployVersion]); v != "" { + return v + } + if c.Labels["teploy.app"] != "" { + if v := strings.TrimSpace(c.Labels["teploy.version"]); v != "" { + return v + } + } + return strings.TrimSpace(imageFacts.Labels[ociLabelVersion]) +} + +// resolveSiblingImage maps docker ps's 12-hex image ID to the image's first +// repo tag when the facts hold one (A52 runs containers from the immutable +// ID); a name-form ref is returned unchanged. Best-effort: an untagged or +// missing image keeps the ID. +func resolveSiblingImage(psImage string, imageFacts docker.ImageFacts) string { + if psImage == "" || !docker.IsImageID(psImage) || len(imageFacts.RepoTags) == 0 { + return psImage + } + return imageFacts.RepoTags[0] +} diff --git a/internal/cli/server_siblings_test.go b/internal/cli/server_siblings_test.go new file mode 100644 index 0000000..5042dbb --- /dev/null +++ b/internal/cli/server_siblings_test.go @@ -0,0 +1,517 @@ +package cli + +// `teploy server siblings` tests. Same discipline as server_audit_test.go: +// one fixture models a server through the ssh MockExecutor — synthetic +// docker output, real parse and detection stages — and the read-only pin at +// the bottom is the command's safety property: nothing it issues may mutate, +// and the whole observation is at most two remote commands. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "testing" + "time" + + "github.com/useteploy/teploy/internal/ssh" +) + +var siblingsFixtureNow = time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + +func siblingsImageID(seed string) string { + // A deterministic full sha256-shaped id for fixture images (hex only — + // IsImageID rejects anything else). + sum := sha256.Sum256([]byte("siblings-fixture:" + seed)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// siblingsFixture models one server's docker answers: the `docker ps --all` +// rows and the batched inspect output (port binds + image facts in one +// command, exactly as InspectSiblings issues it). +type siblingsFixture struct { + containers string // docker ps --all --format rows + inspectPorts string // docker inspect half: /name\t{ports} lines + inspectImgs string // docker image inspect half: id\t[tags]\t{labels} lines + + psErr error // the container listing fails + extra []ssh.MockCommand + noInspect bool // the second round returns nothing at all +} + +func (fx siblingsFixture) inspectOutput() string { + if fx.noInspect { + return "" + } + out := fx.inspectPorts + if fx.inspectImgs != "" { + out += "\n__TEPLOY_SIBLINGS_IMAGES__\n" + fx.inspectImgs + } + return out +} + +func (fx siblingsFixture) exec() *ssh.MockExecutor { + cmds := append([]ssh.MockCommand{}, fx.extra...) + cmds = append(cmds, ssh.MockCommand{Match: "docker ps --all ", Output: fx.containers, Err: fx.psErr}) + if !fx.noInspect { + cmds = append(cmds, ssh.MockCommand{Match: "docker inspect --format", Output: fx.inspectOutput()}) + } + return ssh.NewMockExecutor("192.0.2.10", cmds...) +} + +func (fx siblingsFixture) collect(t *testing.T) serverSiblingsEnvelope { + t.Helper() + return collectServerSiblings(context.Background(), fx.exec(), "infra-home", "192.0.2.10", siblingsFixtureNow) +} + +// siblingsKitchenSink: one server carrying one of everything — an observe +// app (two containers) found by its own labels, a dash container found +// through its IMAGE's label, a forgejo found through its OCI labels, an +// unrecognised product passed through verbatim, and ordinary containers +// (caddy, a teploy app that is not a product, a database) that must not +// appear anywhere. +func siblingsKitchenSink() siblingsFixture { + containers := strings.Join([]string{ + auditContainer("caddy", "caddy:2", "running", "2026-05-01 00:00:00 +0000 UTC", nil), + auditContainer("blog-web-v3", "blog:v3", "running", "2026-11-04 10:00:00 +0000 UTC", + map[string]string{"teploy.app": "blog", "teploy.process": "web", "teploy.version": "v3"}), + auditContainer("blog-db", "postgres:16", "running", "2026-11-04 10:00:00 +0000 UTC", + map[string]string{"teploy.app": "blog", "teploy.role": "accessory"}), + auditContainer("dash-web-1a2b3c4", "ghcr.io/useteploy/dash:v0.3.1", "running", "2026-11-04 09:00:00 +0000 UTC", nil), + auditContainer("forgejo", "codeberg.org/forgejo/forgejo:11", "running", "2026-08-01 00:00:00 +0000 UTC", nil), + auditContainer("lullmail-web-9f8e7d6", "ghcr.io/fylun/lullmail:latest", "running", "2026-10-01 00:00:00 +0000 UTC", + map[string]string{"dev.teploy.product": "lullmail", "teploy.app": "lullmail", "teploy.version": "9f8e7d6"}), + auditContainer("observe-web-7cf98cae", "ghcr.io/useteploy/observe:7cf98cae", "running", "2026-11-05 08:00:00 +0000 UTC", + map[string]string{"dev.teploy.product": "observe", "teploy.app": "observe", "teploy.process": "web", "teploy.version": "7cf98cae"}), + auditContainer("observe-worker-7cf98cae", "ghcr.io/useteploy/observe:7cf98cae", "running", "2026-11-05 08:00:00 +0000 UTC", + map[string]string{"dev.teploy.product": "observe", "teploy.app": "observe", "teploy.process": "worker", "teploy.version": "7cf98cae"}), + }, "\n") + ports := strings.Join([]string{ + `/blog-db {}`, + `/blog-web-v3 {}`, + `/caddy {"80/tcp":null,"443/tcp":null}`, + `/dash-web-1a2b3c4 ` + `{"3000/tcp":[{"HostIp":"127.0.0.1","HostPort":"49221"},{"HostIp":"::","HostPort":"49221"}]}`, + `/forgejo ` + `{"2222/tcp":null,"3000/tcp":[{"HostIp":"127.0.0.1","HostPort":"3000"}]}`, + `/lullmail-web-9f8e7d6 ` + `{"8080/tcp":[{"HostIp":"0.0.0.0","HostPort":"8080"}]}`, + `/observe-web-7cf98cae ` + `{"3000/tcp":[{"HostIp":"100.108.123.49","HostPort":"3000"}]}`, + `/observe-worker-7cf98cae {}`, + }, "\n") + img := func(seed string, tags string, labels string) string { + return siblingsImageID(seed) + "\t" + tags + "\t" + labels + } + images := strings.Join([]string{ + img("blog", `["blog:v3"]`, `{}`), + img("caddy", `["caddy:2"]`, `{"org.opencontainers.image.source":"https://github.com/caddyserver/caddy"}`), + img("forgejo", `["codeberg.org/forgejo/forgejo:11"]`, `{"org.opencontainers.image.source":"https://codeberg.org/forgejo/forgejo","org.opencontainers.image.title":"Forgejo application container","org.opencontainers.image.version":"11.0.1"}`), + img("lullmail", `["ghcr.io/fylun/lullmail:latest"]`, `{}`), + img("dash", `["ghcr.io/useteploy/dash:v0.3.1"]`, `{"dev.teploy.product":"dash","org.opencontainers.image.version":"v0.3.1"}`), + img("observe", `["ghcr.io/useteploy/observe:7cf98cae"]`, `{"dev.teploy.product":"observe","org.opencontainers.image.version":"1.4.0"}`), + img("postgres", `["postgres:16"]`, `{"org.opencontainers.image.source":"https://github.com/docker-library/postgres"}`), + }, "\n") + return siblingsFixture{containers: containers, inspectPorts: ports, inspectImgs: images} +} + +func siblingsByContainer(env serverSiblingsEnvelope, name string) (siblingDTO, bool) { + for _, s := range env.Siblings { + if s.Container == name { + return s, true + } + } + return siblingDTO{}, false +} + +func TestServerSiblings_DetectionAndSources(t *testing.T) { + env := siblingsKitchenSink().collect(t) + if env.State != auditStateOK { + t.Fatalf("state = %s (%s), want ok", env.State, env.Error) + } + if len(env.Siblings) != 4 { + t.Fatalf("siblings = %+v, want dash, forgejo and the two observe containers", env.Siblings) + } + for _, tt := range []struct { + container, product, source, version, app, image string + hints []siblingPortHint + }{ + { + container: "dash-web-1a2b3c4", product: "dash", source: "image_label", version: "v0.3.1", image: "ghcr.io/useteploy/dash:v0.3.1", + hints: []siblingPortHint{ + {HostIP: "127.0.0.1", HostPort: 49221, ContainerPort: 3000, Proto: "tcp"}, + {HostIP: "::", HostPort: 49221, ContainerPort: 3000, Proto: "tcp"}, + }, + }, + { + container: "forgejo", product: "forgejo", source: "oci_label", version: "11.0.1", image: "codeberg.org/forgejo/forgejo:11", + hints: []siblingPortHint{{HostIP: "127.0.0.1", HostPort: 3000, ContainerPort: 3000, Proto: "tcp"}}, + }, + { + container: "observe-web-7cf98cae", product: "observe", source: "label", version: "7cf98cae", app: "observe", image: "ghcr.io/useteploy/observe:7cf98cae", + hints: []siblingPortHint{{HostIP: "100.108.123.49", HostPort: 3000, ContainerPort: 3000, Proto: "tcp"}}, + }, + { + container: "observe-worker-7cf98cae", product: "observe", source: "label", version: "7cf98cae", app: "observe", image: "ghcr.io/useteploy/observe:7cf98cae", + hints: []siblingPortHint{}, + }, + } { + got, ok := siblingsByContainer(env, tt.container) + if !ok { + t.Fatalf("%s missing from siblings: %+v", tt.container, env.Siblings) + } + want := siblingDTO{Container: tt.container, Product: tt.product, Version: tt.version, App: tt.app, + State: "running", Image: tt.image, PortHints: tt.hints, Source: tt.source} + if !reflect.DeepEqual(got, want) { + t.Errorf("%s =\n%+v\nwant\n%+v", tt.container, got, want) + } + } + // The unrecognised product passes through verbatim. + if len(env.UnknownLabeled) != 1 || env.UnknownLabeled[0].Product != "lullmail" || env.UnknownLabeled[0].Container != "lullmail-web-9f8e7d6" { + t.Fatalf("unknown_labeled = %+v, want the lullmail row passed through verbatim", env.UnknownLabeled) + } + if env.UnknownLabeled[0].App != "lullmail" || env.UnknownLabeled[0].Version != "9f8e7d6" || len(env.UnknownLabeled[0].PortHints) != 1 { + t.Errorf("unknown row lost its hints: %+v", env.UnknownLabeled[0]) + } + // Ordinary containers never appear. + for _, name := range []string{"caddy", "blog-web-v3", "blog-db"} { + if s, ok := siblingsByContainer(env, name); ok { + t.Errorf("%s is not a sibling: %+v", name, s) + } + } +} + +func TestServerSiblings_VersionPriority(t *testing.T) { + tests := []struct { + name string + labels map[string]string + imageLabels string + wantVersion string + wantOmitted bool // version field absent from JSON + }{ + { + name: "dev product version wins", + labels: map[string]string{"dev.teploy.product": "ship", "dev.teploy.version": "2.0.0", "teploy.app": "ship", "teploy.version": "1f2e3d4"}, + imageLabels: `{"org.opencontainers.image.version":"9.9.9"}`, + wantVersion: "2.0.0", + }, + { + name: "teploy release version for a teploy app", + labels: map[string]string{"dev.teploy.product": "ship", "teploy.app": "ship", "teploy.version": "1f2e3d4"}, + imageLabels: `{"org.opencontainers.image.version":"9.9.9"}`, + wantVersion: "1f2e3d4", + }, + { + name: "teploy version without a teploy app does not count", + labels: map[string]string{"dev.teploy.product": "ship", "teploy.version": "1f2e3d4"}, + imageLabels: `{"org.opencontainers.image.version":"9.9.9"}`, + wantVersion: "9.9.9", + }, + { + name: "image oci version is the last resort", + labels: map[string]string{"dev.teploy.product": "ship"}, + imageLabels: `{"org.opencontainers.image.version":"9.9.9"}`, + wantVersion: "9.9.9", + }, + { + name: "nothing says", + labels: map[string]string{"dev.teploy.product": "ship"}, + imageLabels: `{}`, + wantVersion: "", + wantOmitted: true, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fx := siblingsFixture{ + containers: auditContainer("ship-web-1", "example/ship:1", "running", "2026-11-04 10:00:00 +0000 UTC", tt.labels), + inspectPorts: "/ship-web-1\t{}", + inspectImgs: fmt.Sprintf("%s\t%s\t%s", siblingsImageID(fmt.Sprintf("ship%d", i)), `["example/ship:1"]`, tt.imageLabels), + } + env := fx.collect(t) + if len(env.Siblings) != 1 { + t.Fatalf("siblings = %+v", env.Siblings) + } + if env.Siblings[0].Version != tt.wantVersion { + t.Errorf("version = %q, want %q", env.Siblings[0].Version, tt.wantVersion) + } + raw, err := json.Marshal(env.Siblings[0]) + if err != nil { + t.Fatal(err) + } + if hasVersion := strings.Contains(string(raw), `"version"`); hasVersion == tt.wantOmitted { + t.Errorf("json %s: version present = %v, want omitted %v", raw, hasVersion, tt.wantOmitted) + } + }) + } +} + +func TestServerSiblings_ContainerLabelBeatsImageAndForgejoFamilies(t *testing.T) { + // A container carrying dev.teploy.product never falls through to the + // image's differing label (source stays "label"), and the forgejo + // family detection covers gitea's source label too. + fx := siblingsFixture{ + containers: strings.Join([]string{ + auditContainer("pinned", "some/registry/observe-fork:2", "running", "2026-11-04 10:00:00 +0000 UTC", + map[string]string{"dev.teploy.product": "observe"}), + auditContainer("gitea", "gitea/gitea:1.22", "running", "2026-08-01 00:00:00 +0000 UTC", nil), + }, "\n"), + inspectPorts: "/gitea\t{}\n/pinned\t{}", + inspectImgs: strings.Join([]string{ + siblingsImageID("fork") + "\t" + `["some/registry/observe-fork:2"]` + "\t" + `{"dev.teploy.product":"dash"}`, + siblingsImageID("gitea") + "\t" + `["gitea/gitea:1.22"]` + "\t" + `{"org.opencontainers.image.source":"https://github.com/go-gitea/gitea"}`, + }, "\n"), + } + env := fx.collect(t) + pinned, ok := siblingsByContainer(env, "pinned") + if !ok || pinned.Product != "observe" || pinned.Source != siblingSourceLabel { + t.Errorf("the container's own label wins: %+v", pinned) + } + gitea, ok := siblingsByContainer(env, "gitea") + if !ok || gitea.Product != "forgejo" || gitea.Source != siblingSourceOCI { + t.Errorf("gitea images are the forgejo family: %+v", gitea) + } +} + +func TestServerSiblings_ImageIDRefResolvesToTag(t *testing.T) { + // teploy deploys run containers from the immutable image ID (A52): + // the row reports the artifact tag, not docker ps's 12-hex form. + id := siblingsImageID("tagged") + short := strings.TrimPrefix(id, "sha256:")[:12] + fx := siblingsFixture{ + containers: auditContainer("dash-web-1a2b3c4", short, "running", "2026-11-04 09:00:00 +0000 UTC", nil), + inspectPorts: `/dash-web-1a2b3c4 {}`, + inspectImgs: id + "\t" + `["ghcr.io/useteploy/dash:v0.3.1"]` + "\t" + `{"dev.teploy.product":"dash"}`, + } + env := fx.collect(t) + got, ok := siblingsByContainer(env, "dash-web-1a2b3c4") + if !ok { + t.Fatalf("ID-created sibling not detected: %+v", env.Siblings) + } + if got.Image != "ghcr.io/useteploy/dash:v0.3.1" { + t.Errorf("image = %q, want the resolved repo tag", got.Image) + } +} + +func TestServerSiblings_NoneFound(t *testing.T) { + fx := siblingsFixture{ + containers: strings.Join([]string{ + auditContainer("caddy", "caddy:2", "running", "2026-05-01 00:00:00 +0000 UTC", nil), + auditContainer("blog-web-v3", "blog:v3", "running", "2026-11-04 10:00:00 +0000 UTC", + map[string]string{"teploy.app": "blog", "teploy.version": "v3"}), + }, "\n"), + inspectPorts: "/blog-web-v3\t{}\n/caddy\t{}", + inspectImgs: siblingsImageID("blog") + "\t" + `["blog:v3"]` + "\t" + `{}`, + } + env := fx.collect(t) + if env.State != auditStateOK { + t.Fatalf("state = %s (%s)", env.State, env.Error) + } + if len(env.Siblings) != 0 || len(env.UnknownLabeled) != 0 { + t.Fatalf("expected nothing, got %+v / %+v", env.Siblings, env.UnknownLabeled) + } + // Empty arrays on the wire, never nulls a consumer must special-case. + raw, _ := json.Marshal(env) + for _, key := range []string{`"siblings":[]`, `"unknown_labeled":[]`} { + if !strings.Contains(string(raw), key) { + t.Errorf("envelope missing %s: %s", key, raw) + } + } +} + +func TestServerSiblings_UnreadableContainersIsPartialNeverClean(t *testing.T) { + fx := siblingsKitchenSink() + fx.psErr = errors.New("docker: cannot connect to the docker daemon") + env := fx.collect(t) + if env.State != auditStatePartial { + t.Fatalf("state = %s, want partial", env.State) + } + if env.Error == "" || !strings.Contains(env.Error, "cannot connect") { + t.Errorf("error = %q, want the listing failure named", env.Error) + } + if len(env.Siblings) != 0 || len(env.UnknownLabeled) != 0 { + t.Errorf("an unlisted server must not report candidates: %+v", env.Siblings) + } +} + +func TestServerSiblings_InspectWholesaleFailureIsPartial(t *testing.T) { + // The second round returned nothing at all (the || true swallowed a + // daemon outage): the observation is partial, and what trip 1 proved — + // the label-detected sibling — is still reported, without port hints. + fx := siblingsKitchenSink() + fx.noInspect = true + env := fx.collect(t) + if env.State != auditStatePartial { + t.Fatalf("state = %s, want partial", env.State) + } + observe, ok := siblingsByContainer(env, "observe-web-7cf98cae") + if !ok || len(observe.PortHints) != 0 { + t.Errorf("label-detected sibling must survive without hints: %+v", observe) + } + if _, ok := siblingsByContainer(env, "dash-web-1a2b3c4"); ok { + t.Errorf("image-label detection is impossible without the second round") + } +} + +func TestServerSiblings_RowsSortedAndDeterministic(t *testing.T) { + first := siblingsKitchenSink().collect(t) + for i := 0; i < 5; i++ { + again := siblingsKitchenSink().collect(t) + if !reflect.DeepEqual(first, again) { + t.Fatalf("observation not deterministic:\n%+v\nvs\n%+v", first, again) + } + } + for i := 1; i < len(first.Siblings); i++ { + if first.Siblings[i-1].Container > first.Siblings[i].Container { + t.Errorf("siblings not sorted by container: %s after %s", first.Siblings[i].Container, first.Siblings[i-1].Container) + } + } +} + +func TestServerSiblings_JSONRoundTrip(t *testing.T) { + env := siblingsKitchenSink().collect(t) + raw, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + var decoded serverSiblingsEnvelope + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("round-trip decode: %v", err) + } + again, err := json.Marshal(decoded) + if err != nil { + t.Fatal(err) + } + if string(raw) != string(again) { + t.Fatalf("round-trip changed the envelope:\n%s\nvs\n%s", raw, again) + } + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + for _, key := range []string{"machine_interface", "server", "host", "observed_at", "state", "siblings", "unknown_labeled"} { + if _, ok := doc[key]; !ok { + t.Errorf("envelope missing %q: %s", key, raw) + } + } + if doc["machine_interface"] != float64(MachineInterface) { + t.Errorf("machine_interface = %v", doc["machine_interface"]) + } + for _, s := range append(append([]siblingDTO{}, decoded.Siblings...), decoded.UnknownLabeled...) { + if s.Container == "" || s.Product == "" || s.Image == "" || s.Source == "" || s.State == "" { + t.Errorf("row missing a required field: %+v", s) + } + for _, h := range s.PortHints { + if h.Proto == "" || h.HostPort == 0 || h.ContainerPort == 0 { + t.Errorf("port hint missing fields: %+v", h) + } + } + } +} + +func TestServerSiblings_HumanOutput(t *testing.T) { + env := siblingsKitchenSink().collect(t) + var out strings.Builder + if err := printServerSiblings(&out, env); err != nil { + t.Fatal(err) + } + text := out.String() + for _, want := range []string{"observe-web-7cf98cae", "forgejo", "100.108.123.49:3000->3000/tcp", "lullmail", "unrecognised"} { + if !strings.Contains(text, want) { + t.Errorf("human output missing %q:\n%s", want, text) + } + } + + empty := siblingsFixture{containers: auditContainer("caddy", "caddy:2", "running", "2026-05-01 00:00:00 +0000 UTC", nil), + inspectPorts: "/caddy\t{}"}.collect(t) + out.Reset() + if err := printServerSiblings(&out, empty); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "No sibling products found") { + t.Errorf("empty server output = %q", out.String()) + } + + unreachable := serverSiblingsEnvelope{State: auditStateUnreachable, Error: "ssh: no route to host"} + if err := printServerSiblings(&out, unreachable); err == nil { + t.Error("an unreachable server is an error in the human form") + } +} + +// The read-only pin, after TestServerAudit_EveryCommandOnlyReads: run the +// kitchen-sink observation through a recording wrapper and refuse any +// mutating verb, docker subcommand outside the read set, stdin or upload — +// and pin that the whole observation is at most TWO remote commands. +func TestServerSiblings_EveryCommandOnlyReads(t *testing.T) { + base := siblingsKitchenSink().exec() + rec := &recordingExecutor{Executor: base} + env := collectServerSiblings(context.Background(), rec, "infra-home", "192.0.2.10", siblingsFixtureNow) + if env.State != auditStateOK { + t.Fatalf("fixture stopped exercising a read: %+v", env) + } + if len(rec.commands) == 0 { + t.Fatal("no commands recorded") + } + if len(rec.commands) > 2 { + t.Errorf("the observation must be at most two remote commands, ran %d: %v", len(rec.commands), rec.commands) + } + if len(rec.uploads) != 0 { + t.Errorf("siblings uploaded files: %v", rec.uploads) + } + if len(rec.inputs) != 0 { + t.Errorf("siblings streamed stdin to commands: %v", rec.inputs) + } + banned := []string{"rm ", "mv ", "mkdir", "rmdir", "cp ", "touch", "truncate", "tee ", "chmod", "chown", + "kill", "pkill", "reboot", "shutdown", "sed ", "dd ", "mkfs", "mount", "umount", "curl", "wget", + "apt", "yum", "dnf", "pip", "useradd", "passwd", "systemctl ", ">>", "docker run", "docker stop", + "docker rm", "docker rmi", "docker pull", "docker create", "docker exec", "docker build", "docker network"} + for _, cmd := range rec.commands { + for _, verb := range banned { + if strings.Contains(cmd, verb) { + t.Errorf("siblings ran a mutating command (%q): %s", verb, cmd) + } + } + if strings.Contains(cmd, "docker ") { + fields := strings.Fields(cmd) + if len(fields) > 1 && fields[1] != "ps" && fields[1] != "inspect" && fields[1] != "image" { + t.Errorf("siblings ran docker %s: %s", fields[1], cmd) + } + } + } + // Env and anything secret-adjacent must never ride along. + for _, cmd := range rec.commands { + if strings.Contains(cmd, "--env") || strings.Contains(cmd, "secret") { + t.Errorf("siblings touched env or secrets: %s", cmd) + } + } +} + +func TestForgejoFromOCILabels(t *testing.T) { + for _, tt := range []struct { + name string + labels map[string]string + want bool + }{ + {"forgejo source", map[string]string{"org.opencontainers.image.source": "https://codeberg.org/forgejo/forgejo"}, true}, + {"forgejo title", map[string]string{"org.opencontainers.image.title": "forgejo/forgejo binary"}, true}, + {"gitea source", map[string]string{"org.opencontainers.image.source": "https://github.com/go-gitea/gitea"}, true}, + {"case insensitive", map[string]string{"org.opencontainers.image.source": "https://Codeberg.org/Forgejo/Forgejo"}, true}, + {"unrelated", map[string]string{"org.opencontainers.image.source": "https://github.com/docker-library/postgres"}, false}, + {"no labels", nil, false}, + } { + if got := forgejoFromOCILabels(tt.labels); got != tt.want { + t.Errorf("%s: forgejoFromOCILabels = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestServerSiblings_EmptyServerNeedsNoSecondRound(t *testing.T) { + // With no containers there is nothing to inspect; no registration for + // the inspect exists, so a second round would fail the mock — proving + // it never ran. + fx := siblingsFixture{containers: "", noInspect: true} + env := collectServerSiblings(context.Background(), fx.exec(), "infra-home", "192.0.2.10", siblingsFixtureNow) + if env.State != auditStateOK || len(env.Siblings) != 0 || len(env.UnknownLabeled) != 0 { + t.Fatalf("empty server = %+v", env) + } +} From efa702fd9c46331d97067f45a3250b53900b29d9 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:10:57 -0700 Subject: [PATCH 3/4] contracts: siblings-envelope, corpus rev 20 (siblings-v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema derived from the serverSiblingsDTO encoder; fixtures GENERATED from the real collectServerSiblings over a mock executor (the corpus rule — never hand-authored where a producer exists): 'mixed' carries one of each detection class (observe by container label across web+worker, dash by image label, forgejo by OCI labels, an unrecognised product passed through verbatim) and 'none' a server with nothing on it. MANIFEST rev 20 and the artifact table row; both fixtures validated against the schema. --- contracts/MANIFEST.md | 2 + .../siblings-envelope/valid/mixed.json | 93 +++++++++++++++++++ .../siblings-envelope/valid/none.json | 9 ++ .../schema/siblings-envelope.schema.json | 54 +++++++++++ internal/cli/contracts_golden_test.go | 21 +++++ 5 files changed, 179 insertions(+) create mode 100644 contracts/fixtures/siblings-envelope/valid/mixed.json create mode 100644 contracts/fixtures/siblings-envelope/valid/none.json create mode 100644 contracts/schema/siblings-envelope.schema.json diff --git a/contracts/MANIFEST.md b/contracts/MANIFEST.md index f520cbc..3c64024 100644 --- a/contracts/MANIFEST.md +++ b/contracts/MANIFEST.md @@ -10,6 +10,7 @@ Neutron/Nucleus dependency and a public mirror. | Corpus rev | Emitting CLI | Machine Interface | Notes | |---|---|---|---| +| 20 | main (Dash auto-config Programme B: siblings) | 2 | Additive. New artifact siblings-envelope (`teploy server siblings --json`, read-only sibling discovery: teploy products by the `dev.teploy.product` label — the container's own, else its image's — and the forgejo family by the OCI `org.opencontainers.image.title`/`source` labels; per row version hints (dev.teploy.version, the teploy release label for a teploy app, else the image's `org.opencontainers.image.version`), the teploy app name, and `port_hints` from docker inspect's published binds; unrecognised `dev.teploy.product` values pass through verbatim under `unknown_labeled`; state ok/partial/unreachable so an unobserved server is never a clean "no siblings"). Observation only — no probing, no DNS, no env/credentials; at most two remote commands (the shared batched `docker ps`, then one batched inspect for port binds + image labels), pinned read-only by test. Fixtures GENERATED from the real `collectServerSiblings` over a mock executor (mixed: one of each detection class + pass-through; none). version-handshake advertises `siblings-v1` (regenerated mi2.json). No MI bump. | | 19 | main (Dash auto-config Slice 6: cleanup / audit) | 2 | Additive. New artifact server-audit-envelope (`teploy server audit --json`, read-only: findings with stable `id` = kind:subject, severity, evidence, and remedy {argv (null when nothing the CLI can do safely), destructive, confirm}, per-source `checks` where unknown means findings depending on it are missing — never clean — and disk attribution), fixture GENERATED from the real `auditServer` over a mock executor (one of each finding incl. null-remedy, one unknown check). New remedy commands `route remove --app X` (exact-block CAS), `releases prune --keep N` (pins/current/previous/running never pruned) and `server prune --build-cache --dangling`; events `route.remove`, `releases.prune`, `server.prune` (the last under `_fleet`). keep_versions becomes optional: unset → 5 pruned only after a 7-day notice recorded server-side (`deploy/retention.go`), `init` writes 3, explicit 0 keeps everything and records `keep_all_versions` in the applied manifest (unset and N record what they always did, so existing digests hold). version-handshake advertises `server-audit-v1` (regenerated mi2.json). No MI bump. | | 18 | main (Dash auto-config Slice 4: fleet backup engine) | 2 | Additive. New artifacts backup-status-envelope (`backup status --json`: every DB-like container and app volume with coverage state, last success/failure, newest object, last verify, plus target and policy summaries, the aws CLI's `aws_cli_problem` and the target's `incoming_lifecycle`; no secrets), backup-run-envelope (`backup run --all --json`) and backup-policy-envelope (`server backup-policy show/set --json`), fixtures GENERATED from the real DTOs. backup-list rows gain optional `encrypted`, `previous_prefix`, and `database` only with `--databases` or `--adopted` (a caller restoring by id alone would replace the primary); backup-create gains `encrypted` and `objects`; backup-restore gains `pre_backup_id`; backup-target gains `previous_prefixes`. Event kinds `backup.all`, `backup_policy.set` and `backup_target.owners_clear` (a marker removal or refusal) under the pseudo-app `_fleet`; adopted containers log under `_adopted`. version-handshake advertises `backup-fleet-v1` (regenerated mi2.json). No MI bump. | | 17 | main (dash auto-config slice 5: domain health) | 2 | Additive. New artifact domains-check-envelope (`teploy domains check [--server] [--app] [--log-window] --json`: per (server, app, host) DNS via the fixed public resolvers, Caddy certificate failures from its logs — `failures` is null, never 0, on short or unavailable logs — and the route with upstream resolution; per-server state so an unreachable server is explicit), fixture GENERATED from the real `sweepDomains` over a mock executor. version-handshake advertises `domains-check-v1` (regenerated mi2.json). release-record's `caddy` gains optional `tls_http_only` (absent on older records; every caddy release since records true or false). No MI bump. | @@ -62,6 +63,7 @@ Neutron/Nucleus dependency and a public mirror. | backup-policy-envelope | yes (rev 18) | valid x2 (configured, unconfigured; real backupPolicyDTO) | teploy-cli | | domains-check-envelope | yes (rev 17) | valid (one server with each finding, one unreachable; real `sweepDomains`) | teploy-cli | | server-audit-envelope | yes (rev 19) | valid (one of each finding incl. null-remedy, one unknown check; real `auditServer` over a mock executor) | teploy-cli | +| siblings-envelope | yes (rev 20) | valid x2 (mixed detection classes + pass-through, none; real `collectServerSiblings` over a mock executor) | teploy-cli | | releases-envelope | yes (rev 14) | valid x2 (container app, static app; real releasesDTO) | teploy-cli | | rollback-result | yes (rev 14) | valid (real rollbackResult) | teploy-cli | | redeploy-result | yes (rev 14) | valid x2 (teploy-home, folder-home; real redeployDTO) | teploy-cli | diff --git a/contracts/fixtures/siblings-envelope/valid/mixed.json b/contracts/fixtures/siblings-envelope/valid/mixed.json new file mode 100644 index 0000000..4bfd14b --- /dev/null +++ b/contracts/fixtures/siblings-envelope/valid/mixed.json @@ -0,0 +1,93 @@ +{ + "machine_interface": 2, + "server": "infra-home", + "host": "192.0.2.10", + "observed_at": "2026-11-05T12:00:00Z", + "state": "ok", + "siblings": [ + { + "container": "dash-web-1a2b3c4", + "product": "dash", + "version": "v0.3.1", + "state": "running", + "image": "ghcr.io/useteploy/dash:v0.3.1", + "port_hints": [ + { + "host_ip": "127.0.0.1", + "host_port": 49221, + "container_port": 3000, + "proto": "tcp" + }, + { + "host_ip": "::", + "host_port": 49221, + "container_port": 3000, + "proto": "tcp" + } + ], + "source": "image_label" + }, + { + "container": "forgejo", + "product": "forgejo", + "version": "11.0.1", + "state": "running", + "image": "codeberg.org/forgejo/forgejo:11", + "port_hints": [ + { + "host_ip": "127.0.0.1", + "host_port": 3000, + "container_port": 3000, + "proto": "tcp" + } + ], + "source": "oci_label" + }, + { + "container": "observe-web-7cf98cae", + "product": "observe", + "version": "7cf98cae", + "app": "observe", + "state": "running", + "image": "ghcr.io/useteploy/observe:7cf98cae", + "port_hints": [ + { + "host_ip": "100.108.123.49", + "host_port": 3000, + "container_port": 3000, + "proto": "tcp" + } + ], + "source": "label" + }, + { + "container": "observe-worker-7cf98cae", + "product": "observe", + "version": "7cf98cae", + "app": "observe", + "state": "running", + "image": "ghcr.io/useteploy/observe:7cf98cae", + "port_hints": [], + "source": "label" + } + ], + "unknown_labeled": [ + { + "container": "lullmail-web-9f8e7d6", + "product": "lullmail", + "version": "9f8e7d6", + "app": "lullmail", + "state": "running", + "image": "ghcr.io/fylun/lullmail:latest", + "port_hints": [ + { + "host_ip": "0.0.0.0", + "host_port": 8080, + "container_port": 8080, + "proto": "tcp" + } + ], + "source": "label" + } + ] +} diff --git a/contracts/fixtures/siblings-envelope/valid/none.json b/contracts/fixtures/siblings-envelope/valid/none.json new file mode 100644 index 0000000..02e7c08 --- /dev/null +++ b/contracts/fixtures/siblings-envelope/valid/none.json @@ -0,0 +1,9 @@ +{ + "machine_interface": 2, + "server": "infra-home", + "host": "192.0.2.10", + "observed_at": "2026-11-05T12:00:00Z", + "state": "ok", + "siblings": [], + "unknown_labeled": [] +} diff --git a/contracts/schema/siblings-envelope.schema.json b/contracts/schema/siblings-envelope.schema.json new file mode 100644 index 0000000..43e5e95 --- /dev/null +++ b/contracts/schema/siblings-envelope.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://teploy.github.io/contracts/schema/siblings-envelope.schema.json", + "title": "`teploy server siblings --json` envelope: sibling product candidates on a server, as the containers and their images report them (rev 20). Observation only — no probing, no DNS, no credentials; the consumer confirms via each product's own discovery endpoint.", + "type": "object", + "required": ["machine_interface", "server", "host", "observed_at", "state", "siblings", "unknown_labeled"], + "properties": { + "machine_interface": {"type": "integer", "minimum": 1}, + "server": {"type": "string", "minLength": 1}, + "host": {"type": "string"}, + "observed_at": {"type": "string", "format": "date-time"}, + "state": {"enum": ["ok", "partial", "unreachable"], "description": "partial means the container listing or the batched inspect failed: empty arrays are 'unobserved', never 'no siblings'"}, + "error": {"type": "string"}, + "siblings": { + "type": "array", + "items": {"$ref": "#/$defs/sibling"}, + "description": "recognised candidates: teploy products (dash, observe, ship) and the forgejo family, one row per container (a multi-container product app yields several rows; the consumer groups them by its own probe)" + }, + "unknown_labeled": { + "type": "array", + "items": {"$ref": "#/$defs/sibling"}, + "description": "containers carrying a dev.teploy.product value this CLI does not recognise, passed through verbatim — a product newer than the CLI stays visible; the consumer decides what it knows" + } + }, + "$defs": { + "sibling": { + "type": "object", + "required": ["container", "product", "state", "image", "port_hints", "source"], + "properties": { + "container": {"type": "string", "minLength": 1}, + "product": {"type": "string", "minLength": 1}, + "version": {"type": "string", "description": "hint, in priority order: the container's dev.teploy.version label, the teploy release label when the container belongs to a teploy app, the image's org.opencontainers.image.version; absent when nothing says"}, + "app": {"type": "string", "description": "the teploy app name when the container is teploy-managed; absent otherwise"}, + "state": {"type": "string", "description": "docker's container state (running, exited, …)"}, + "image": {"type": "string", "description": "the image ref docker reports, with a 12-hex image ID resolved to its first repo tag when the facts hold one"}, + "port_hints": { + "type": "array", + "items": { + "type": "object", + "required": ["host_ip", "host_port", "container_port", "proto"], + "properties": { + "host_ip": {"type": "string", "description": "as docker reports the bind: 0.0.0.0 (every interface), :: (v6), 127.0.0.1 (loopback only), or a mesh/tailnet address"}, + "host_port": {"type": "integer", "minimum": 1, "maximum": 65535}, + "container_port": {"type": "integer", "minimum": 1, "maximum": 65535}, + "proto": {"enum": ["tcp", "udp"]} + } + }, + "description": "published host binds where the product's discovery endpoint may live; hints, not endpoints" + }, + "source": {"enum": ["label", "image_label", "oci_label"], "description": "label: the container's own dev.teploy.product; image_label: the image's; oci_label: the image's org.opencontainers.image.title/source names the forgejo family"} + } + } + } +} diff --git a/internal/cli/contracts_golden_test.go b/internal/cli/contracts_golden_test.go index 65b7a65..131d62d 100644 --- a/internal/cli/contracts_golden_test.go +++ b/internal/cli/contracts_golden_test.go @@ -624,6 +624,27 @@ func TestContractsServerAuditGolden(t *testing.T) { writeFixture(t, "server-audit-envelope/valid/findings.json", auditKitchenSink().audit(t)) } +// TestContractsSiblingsGolden pins `teploy server siblings --json` +// (rev 20, siblings-v1): one server carrying each detection class — a teploy +// product by its container label (a two-container app), one by its IMAGE's +// label, forgejo by its OCI labels, an unrecognised product passed through +// verbatim — and one with nothing on it. Generated from the real +// `collectServerSiblings` over a mock executor. +func TestContractsSiblingsGolden(t *testing.T) { + writeFixture(t, "siblings-envelope/valid/mixed.json", siblingsKitchenSink().collect(t)) + + none := siblingsFixture{ + containers: strings.Join([]string{ + auditContainer("caddy", "caddy:2", "running", "2026-05-01 00:00:00 +0000 UTC", nil), + auditContainer("blog-web-v3", "blog:v3", "running", "2026-11-04 10:00:00 +0000 UTC", + map[string]string{"teploy.app": "blog", "teploy.version": "v3"}), + }, "\n"), + inspectPorts: "/blog-web-v3\t{}\n/caddy\t{}", + inspectImgs: siblingsImageID("blog") + "\t" + `["blog:v3"]` + "\t" + `{}`, + } + writeFixture(t, "siblings-envelope/valid/none.json", none.collect(t)) +} + func TestContractsBackupFleetGolden(t *testing.T) { at := time.Date(2026, 11, 4, 3, 17, 4, 0, time.UTC) failedAt := at.Add(-time.Hour) From 18f528dd826d065a24600c935e54471b494fa628 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:11:01 -0700 Subject: [PATCH 4/4] docs: sibling discovery (Programme B), changelog and audit opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: a Sibling discovery section (what the labels are, what a row carries, what deliberately is NOT there — probing, DNS, credentials — and the state vocabulary) plus the command-list entry. CHANGELOG Unreleased. AUDIT_OPEN: the new slice's deliberate report-only boundary, the forgejo substring trade-off, the one-row-per-container decision, the every-binds read, and the not-yet-live caveat. --- AUDIT_OPEN.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 16 ++++++++++++++++ README.md | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index a275b7d..350ddf9 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -1,5 +1,57 @@ # Open audit items +## 2026-11-05 — Dash auto-config Programme B CLI: server siblings + +Plan: `../_internal/DASH_AUTOCONFIG_PLAN_2026-11-03.md` section 10. Branch +`programme-b-siblings`. `teploy server siblings --json` (read-only +discovery: dev.teploy.product container/image label, forgejo via OCI labels, +version hints, published-port hints, unknown values passed through), +capability `siblings-v1`, contracts rev 20. + +Deliberately out of scope (the plan puts them on the consumer): +- Probing, DNS, and anything URL-shaped: the command only observes labels + and port binds. Dash probes `/.well-known/teploy.json` and forgejo's + `/api/v1/version`; credential minting and env writes need a confirmed + `instance_id` + admin consent, neither of which exists here. +- Grouping a product's containers: one teploy app with web + worker + containers yields two rows (both carry the product label). The consumer + groups by its own probe; a CLI-side dedupe would guess which container + is "the" instance — exactly the substring guessing the plan forbids. + +Open / deferred: +- Forgejo detection is substring-based by design (`forgejo/forgejo` or + `gitea` in the image's OCI title/source labels): upstream forgejo/gitea + images carry no teploy label, so their OCI labels are the only signal. A + third-party image that merely mentions gitea in its source label becomes + a candidate — it costs the consumer one failed probe, never a wrong + connection. `teploy.engine=`-style override (a manual label pinning the + product) is the escape hatch and is already honoured: dev.teploy.product + on the container wins over everything the image says. +- Image facts come from the image as the host knows it: a container run + from an image that was since re-tagged reports the CURRENT tag's labels. + Same trust domain as `docker ps`; not solvable without a registry read + this read-only command will not do. +- A stopped sibling is listed with its docker state (`state: exited`): a + candidate that is down is information, not noise. Consumers should read + `state` before probing. +- The port-hint read includes every container's binds (one batched inspect + for the whole host) rather than only the candidates': image-label + detection lands in the same round trip, and a third trip to re-inspect + just-discovered candidates would double the command's cost for no + privacy or safety gain. Both reads are inspects. +- Version gates: Dash must read `siblings-v1` from `teploy version --json` + before relying on the envelope (plan: version-gated). Not yet exercised + live against the fleet: first real run happens with the Dash slice. + +Verified: unit/hermetic only (mock SSH executors, the same discipline as +`server audit`): every detection source and fallback, version priority, +port-hint extraction (dual-stack, loopback, tailnet, udp, +exposed-but-unpublished), pass-through of unknown products, partial vs ok +states (unlistable server, wholesale inspect failure), determinism, +JSON round-trip, ID-created image tag resolution, and the read-only pin +(no mutating verb or docker subcommand, no stdin, no uploads, ≤2 remote +commands). Schemas validated against the generated fixtures. + ## 2026-11-05 — Dash auto-config Slice 6 CLI: cleanup / audit (P5) Plan: `../_internal/DASH_AUTOCONFIG_PLAN_2026-11-03.md` (section 9). Branch diff --git a/CHANGELOG.md b/CHANGELOG.md index 65fc05b..8ae7395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to teploy are documented here. Format follows [Keep a Change ## [Unreleased] +### Added + +- `teploy server siblings --json`: read-only sibling discovery + (Programme B) — teploy products (dash, observe, ship) by the + `dev.teploy.product` label (the container's own, else its image's) and the + forgejo family by the OCI labels on its image, each row with version hints, + the teploy app name when managed, and published-port hints where the + product's `/.well-known/teploy.json` may live. An unrecognised + `dev.teploy.product` value passes through verbatim under `unknown_labeled` + (a product newer than the CLI stays visible; the consumer decides). + Observes only — no probing, no DNS, no env or credentials — in at most two + remote commands, both pinned read-only by test. An unobserved server + reports `state: partial|unreachable`, never a clean "no siblings". +- Contracts: `siblings-envelope` (schema + generated fixtures, corpus rev + 20); version-handshake advertises `siblings-v1`. + ## [0.1.53] - 2026-11-05 ### Added diff --git a/README.md b/README.md index 4ce7a77..2896bd8 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,7 @@ teploy plan --out plan.json # write a bound plan record (config di teploy apply plan.json # execute a reviewed plan; refuses naming what drifted since it was made teploy scale # multi-server deploy + LB update teploy server audit # read-only: what is left over and what fixes it +teploy server siblings # read-only: teploy products and forgejo running on a server teploy version / update # version info and self-update ``` @@ -632,6 +633,43 @@ network), no files it still serves, nothing unreadable — and commits under an exact-block compare-and-swap, so a deploy that rewrites the block in between is never undone. +### Sibling discovery + +```bash +teploy server siblings # teploy products + forgejo on that server +teploy server siblings --json # contracts/schema/siblings-envelope.schema.json +``` + +What Dash (or you) runs before offering to connect the products that share a +server. Each teploy product image carries +`LABEL dev.teploy.product=` and serves an unauthenticated +`GET /.well-known/teploy.json`; this command finds the candidates without +probing anything: + +- **product**: the container's `dev.teploy.product` label (`source: label`), + else its image's (`source: image_label`), else — for the forgejo family, + whose images carry no teploy label — the image's OCI labels + (`org.opencontainers.image.title`/`source` naming forgejo or gitea → + `source: oci_label`). A `dev.teploy.product` value the CLI does not + recognise passes through verbatim under `unknown_labeled`: a product newer + than the CLI stays visible, and the consumer decides what it knows. +- **version / app**: hints only — `dev.teploy.version`, else the teploy + release label when the container is teploy-managed, else the image's + `org.opencontainers.image.version`. +- **port_hints**: the container's published host binds (loopback and + tailnet/mesh binds included as-is) — where + `/.well-known/teploy.json` (or forgejo's `/api/v1/version`) may live. + +One row per container: an app with web + worker containers yields two rows, +grouped by the consumer's probe. Observation only — no DNS, no HTTP, no URL +guessing, no env or credentials — and read-only end to end (a test pins every +command it issues only reads; the whole thing is at most two SSH round +trips). Confirming a candidate, minting credentials or writing env needs its +confirmed `instance_id` and an admin's consent — that is the consumer's +surface, never this command's. With `--json` the exit code is 0 whenever the +envelope was produced; read `state` (`ok` / `partial` / `unreachable`) — an +unobserved server is never a clean "no siblings". + ### Accessories ``` teploy accessory list # list running accessories