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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions ci/bootlog/bootlog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

// Package bootlog defines structured boot event types shared between
// cihostlet (collector) and API consumers.
package bootlog

import (
"github.com/tailscale/tb/ci/ciid"
"github.com/tailscale/tb/ci/statustype"
)

// Event is a single boot log entry for a VM. Each event has a relative
// timestamp and exactly one of the optional fields set.
type Event struct {
T float64 `json:"t"` // seconds since VM creation
State statustype.GuestState `json:"state,omitzero"` // state transition (e.g. "starting-vm")
Log string `json:"log,omitzero"` // raw log line from ciguestlet
Ready bool `json:"ready,omitzero"` // terminal: VM is SSH-ready
Error string `json:"error,omitzero"` // terminal: boot failed
SSH *SSHInfo `json:"ssh,omitzero"` // direct cihostlet SSH proxy details, set by cimgr on ready
}

// SSHInfo tells an API caller how to reconnect directly to cihostlet for SSH
// once a VM is ready. The bearer token itself is returned by VM creation and
// should be presented in Authorization: Bearer <token> when connecting to URL.
type SSHInfo struct {
Hostlet ciid.HostletName `json:"hostlet"`
URL string `json:"url"`
BearerToken string `json:"bearer_token,omitzero"` // authorization secret for cihostlet's SSH proxy
}
101 changes: 101 additions & 0 deletions ci/ciid/ciid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

// Package ciid contains identifier types used in the CI system.
package ciid

import (
"strconv"
"strings"
)

// GuestletName is a globally unique guestlet name of the form "HOSTNAME-N-UNIXTIME",
// e.g. "ci-linux-1-4-1774827890" for ci-linux-1 host, slot 4, unix time 1774827890.
type GuestletName string

// Parse decomposes a GuestletName into its three components: the hostlet name
// (e.g. "ci-linux-1"), the 1-indexed slot number (e.g. 8), and the opaque
// suffix (e.g. "1774827890", typically a unix timestamp). It returns ok=false
// if the name doesn't match the expected format.
//
// It works backwards from the end using LastIndexByte so that hostlet names
// containing extra hyphens are handled correctly and no allocations are needed.
func (n GuestletName) Parse() (hostletName HostletName, slot int, suffix string, ok bool) {
s := string(n)

// Cut off the suffix (after the last hyphen).
i := strings.LastIndexByte(s, '-')
if i <= 0 {
return
}
suffix = s[i+1:]
s = s[:i]

// Cut off the slot number (after the new last hyphen).
i = strings.LastIndexByte(s, '-')
if i <= 0 {
return "", 0, "", false
}
slot, err := strconv.Atoi(s[i+1:])
if err != nil {
return "", 0, "", false
}
hostletName = HostletName(s[:i])
ok = true
return
}

// Runner returns the guestlet name as a GitHubRunnerName, since guestlets
// register as GitHub Actions runners using their guestlet name.
func (n GuestletName) Runner() GitHubRunnerName { return GitHubRunnerName(n) }

// GitHubRunnerName is the name of a GitHub Actions runner. For runners managed
// by cihostlet/ciguestlet, these are GuestletName values. But the GitHub org may
// also contain legacy or third-party runners with other naming conventions.
type GitHubRunnerName string

// Matches reports whether this GitHub runner name corresponds to the given hostlet.
func (n GitHubRunnerName) Matches(hostlet HostletName) bool {
gh := string(n)
h := string(hostlet)
return strings.HasPrefix(gh, h+"-")
}

// HostletName is the globally unique name for a cihostlet of the form "ci-METADATA-N",
// Example hosts:
// * "ci-mac-ec2-m2-1" for the first M2 mac EC2 instance.
// * "ci-linux-5" for the fifth Linux hostlet running on an EC2 instance with nested virtualization enabled.
type HostletName string

// Parse decomposes a HostletName into its metadata (e.g. "linux", or
// "mac-ec2-m2") and number. For example, "ci-mac-ec2-m2-1" parses into
// metadata="mac-ec2-m2" and number=1.
//
// It works backwards from the end using LastIndexByte so that hostlet names
// containing extra hyphens are handled correctly and no allocations are needed.
func (n HostletName) Parse() (metadata string, number int, ok bool) {
s := string(n)
if !strings.HasPrefix(s, "ci-") {
return "", 0, false
}
s = s[3:]

// Cut off the number (after the last hyphen).
i := strings.LastIndexByte(s, '-')
if i <= 0 {
return "", 0, false
}
number, err := strconv.Atoi(s[i+1:])
if err != nil {
return "", 0, false
}
return s[:i], number, true
}

// IsDynamic reports whether the given hostlet name should be considered a
// dynamic hostlet that can be scaled up and down by cimgr. At the time of
// writing (2026-05-21), only ci-linux-N hostlets are dynamic.
func (n HostletName) IsDynamic() bool {
metadata, _, ok := n.Parse()
return ok && metadata == "linux"
}
94 changes: 94 additions & 0 deletions ci/ciid/ciid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package ciid

import "testing"

func TestGuestletNameParse(t *testing.T) {
tests := []struct {
name GuestletName
wantHost HostletName
wantSlot int
wantSuffix string
wantOK bool
}{
// Standard Linux names.
{"ci-linux-1-8-1774827890", "ci-linux-1", 8, "1774827890", true},
{"ci-linux-2-2-1769779953", "ci-linux-2", 2, "1769779953", true},
{"ci-linux-10-1-99", "ci-linux-10", 1, "99", true},

// Mac names with extra hyphens in hostlet name.
{"ci-mac-ec2-m2-1-2-30092025", "ci-mac-ec2-m2-1", 2, "30092025", true},
{"ci-mac-phys-m4-2-1-30092025", "ci-mac-phys-m4-2", 1, "30092025", true},

// Non-numeric suffix is fine (it's opaque).
{"host-1-abc", "host", 1, "abc", true},

// Slot 0 is valid syntactically.
{"h-0-suffix", "h", 0, "suffix", true},

// Too few components.
{"nohyphens", "", 0, "", false},
{"one-field", "", 0, "", false},

// Slot is not a number.
{"a-notanum-suffix", "", 0, "", false},

// Empty string.
{"", "", 0, "", false},

// Empty hostlet name (hyphen at start).
{"-1-suffix", "", 0, "", false},
}
for _, tt := range tests {
host, slot, suffix, ok := tt.name.Parse()
if ok != tt.wantOK || host != tt.wantHost || slot != tt.wantSlot || suffix != tt.wantSuffix {
t.Errorf("GuestletName(%q).Parse() = (%q, %d, %q, %v), want (%q, %d, %q, %v)",
tt.name, host, slot, suffix, ok,
tt.wantHost, tt.wantSlot, tt.wantSuffix, tt.wantOK)
}
}
}

func TestGuestletNameRunner(t *testing.T) {
n := GuestletName("ci-linux-1-2-1234")
r := n.Runner()
if r != "ci-linux-1-2-1234" {
t.Errorf("Runner() = %q, want %q", r, "ci-linux-1-2-1234")
}
}

func TestHostletNameParse(t *testing.T) {
tests := []struct {
name HostletName
wantMeta string
wantNumber int
wantOK bool
}{
// Standard names.
{"ci-linux-1", "linux", 1, true},
{"ci-mac-ec2-m2-1", "mac-ec2-m2", 1, true},
{"ci-linux-5", "linux", 5, true},

// Too few components.
{"", "", 0, false},
{"ci", "", 0, false},
{"ci-", "", 0, false},
{"ci-linux", "", 0, false},
{"ci-linux-", "", 0, false},
{"-linux-", "", 0, false},
{"-1", "", 0, false},

// Number is not a number.
{"ci-linux-notanum", "", 0, false},
}
for _, tt := range tests {
meta, number, ok := tt.name.Parse()
if ok != tt.wantOK || meta != tt.wantMeta || number != tt.wantNumber {
t.Errorf("HostletName(%q).Parse() = (%q, %d, %v), want (%q, %d, %v)",
tt.name, meta, number, ok,
tt.wantMeta, tt.wantNumber, tt.wantOK)
}
}
}
34 changes: 34 additions & 0 deletions ci/cimgr/cimgrapi/cimgrapi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

// Package cimgrapi provides shared types for cimgr's HTTP API.
package cimgrapi

import (
"github.com/tailscale/tb/ci/bootlog"
"github.com/tailscale/tb/ci/ciid"
"github.com/tailscale/tb/ci/guestlet"
)

// CreateVMResponse is the response body for POST /api/vms on cimgr.
// The request body for POST /api/vms is [guestlet.Opts].
//
// All fields are populated on a successful (201 Created) response.
type CreateVMResponse struct {
// VM is the created VM, as reported by the owning cihostlet.
VM *guestlet.Guestlet `json:"vm"`

// Hostlet is the cihostlet on which the VM was scheduled.
Hostlet ciid.HostletName `json:"hostlet"`

// BootLogURL is a URL path on cimgr for streaming the VM's boot log.
// Clients append the ?stream=true query parameter to receive
// newline-delimited [bootlog.Event] JSON objects until either a
// ready or error event terminates the stream.
BootLogURL string `json:"boot_log_url"`

// SSH describes how to reach the VM through the owning cihostlet's
// SSH proxy: where to dial and how to authenticate, including the
// per-VM bearer token that authorizes use of the proxy.
SSH *bootlog.SSHInfo `json:"ssh"`
}
15 changes: 15 additions & 0 deletions ci/cinet/cinet.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

// Package cinet holds network addressing shared between cihostlet and
// ciguestlet.
package cinet

// TailnetV4Net and TailnetV6Net are the ranges Tailscale allocates node
// addresses from. Guest VMs are never given an address in either, and never
// have a legitimate reason to address one: everything the host offers a guest
// is served on the guest's bridge gateway IP.
const (
TailnetV4Net = "100.64.0.0/10"
TailnetV6Net = "fd7a:115c:a1e0::/48"
)
24 changes: 24 additions & 0 deletions ci/cinet/cinet_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package cinet

import (
"net/netip"
"testing"
)

// The constants are formatted into iptables and pf rules, where a typo would
// only surface as a rule-load failure at VM start.
func TestTailnetNetsParse(t *testing.T) {
for _, s := range []string{TailnetV4Net, TailnetV6Net} {
p, err := netip.ParsePrefix(s)
if err != nil {
t.Errorf("ParsePrefix(%q): %v", s, err)
continue
}
if p.Masked() != p {
t.Errorf("%q has bits set below the prefix length; want %s", s, p.Masked())
}
}
}
29 changes: 29 additions & 0 deletions ci/guestlet/compare.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

// Package guestlet provides shared utilities for CI guestlet tooling.
package guestlet

import (
"cmp"

"github.com/tailscale/tb/ci/ciid"
)

// CompareNames compares two guestlet names. If both parse successfully, they
// are ordered by hostlet name, then slot number, then lexically by suffix.
// If either side fails to parse, the raw strings are compared lexically.
func CompareNames(a, b ciid.GuestletName) int {
ah, aslot, asuf, aok := a.Parse()
bh, bslot, bsuf, bok := b.Parse()
if !aok || !bok {
return cmp.Compare(a, b)
}
if c := cmp.Compare(ah, bh); c != 0 {
return c
}
if c := cmp.Compare(aslot, bslot); c != 0 {
return c
}
return cmp.Compare(asuf, bsuf)
}
53 changes: 53 additions & 0 deletions ci/guestlet/compare_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package guestlet

import (
"testing"

"github.com/tailscale/tb/ci/ciid"
)

func TestCompareNames(t *testing.T) {
tests := []struct {
a, b ciid.GuestletName
want int // -1, 0, or 1
}{
// Same hostlet and slot, differ by suffix (lexical on suffix).
{"ci-linux-2-1-100", "ci-linux-2-1-200", -1},
{"ci-linux-2-1-200", "ci-linux-2-1-100", 1},
{"ci-linux-2-1-100", "ci-linux-2-1-100", 0},

// Same hostlet, differ by slot number (numeric).
{"ci-linux-2-1-100", "ci-linux-2-2-100", -1},
{"ci-linux-2-9-100", "ci-linux-2-10-100", -1},
{"ci-linux-2-10-100", "ci-linux-2-9-100", 1},

// Different hostlet names.
{"ci-linux-1-1-100", "ci-linux-2-1-100", -1},
{"ci-linux-2-1-100", "ci-linux-1-1-100", 1},

// Mac names with extra hyphens in hostlet name.
{"ci-mac-ec2-m2-1-1-100", "ci-mac-ec2-m2-1-2-100", -1},
{"ci-mac-ec2-m2-1-2-100", "ci-mac-ec2-m2-1-1-100", 1},
{"ci-mac-ec2-m2-1-1-100", "ci-mac-ec2-m2-1-1-200", -1},

// Lexical suffix comparison (not numeric).
{"ci-linux-1-1-aaa", "ci-linux-1-1-bbb", -1},
{"ci-linux-1-1-9", "ci-linux-1-1-10", 1}, // lexical: "9" > "10"

// Either side fails to parse — fall back to raw lexical.
{"alpha", "beta", -1},
{"beta", "alpha", 1},
{"same", "same", 0},
{"ci-linux-2-9", "ci-linux-2-foo", -1}, // both fail to parse
{"unparseable", "ci-linux-1-1-100", 1}, // lexical: "u" > "c"
}
for _, tt := range tests {
got := CompareNames(tt.a, tt.b)
if got != tt.want {
t.Errorf("CompareNames(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
}
}
Loading
Loading