feat: add deployment foundation and cross-device handoff
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
// Package planner builds offline previews. It does not inspect or change a host.
|
||||
package planner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const ProtocolVersion = 1
|
||||
|
||||
var (
|
||||
idPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,47}$`)
|
||||
digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
|
||||
labelPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
)
|
||||
|
||||
// Intent contains no secrets. ObservedStateDigest is caller-supplied in offline
|
||||
// mode; a future executor must obtain it independently from the target host.
|
||||
type Intent struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
HostID string `json:"hostId"`
|
||||
InstanceID string `json:"instanceId"`
|
||||
AppID string `json:"appId"`
|
||||
PackageDigest string `json:"packageDigest"`
|
||||
ImageDigest string `json:"imageDigest"`
|
||||
Domain string `json:"domain"`
|
||||
ObservedStateDigest string `json:"observedStateDigest"`
|
||||
}
|
||||
|
||||
func (i Intent) Validate() error {
|
||||
if i.ProtocolVersion != ProtocolVersion {
|
||||
return errors.New("unsupported protocol version")
|
||||
}
|
||||
for _, id := range []string{i.HostID, i.InstanceID, i.AppID} {
|
||||
if !idPattern.MatchString(id) {
|
||||
return errors.New("invalid resource identifier")
|
||||
}
|
||||
}
|
||||
for _, digest := range []string{i.PackageDigest, i.ImageDigest, i.ObservedStateDigest} {
|
||||
if !digestPattern.MatchString(digest) {
|
||||
return errors.New("expected a SHA-256 digest")
|
||||
}
|
||||
}
|
||||
labels := strings.Split(i.Domain, ".")
|
||||
if len(i.Domain) > 253 || len(labels) < 2 || net.ParseIP(i.Domain) != nil {
|
||||
return errors.New("expected an ASCII DNS hostname")
|
||||
}
|
||||
for _, label := range labels {
|
||||
if !labelPattern.MatchString(label) {
|
||||
return errors.New("invalid DNS hostname label")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package planner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validIntent() Intent {
|
||||
return Intent{ProtocolVersion: 1, HostID: "host-one", InstanceID: "git-one", AppID: "gitea", PackageDigest: "sha256:" + strings.Repeat("a", 64), ImageDigest: "sha256:" + strings.Repeat("b", 64), Domain: "git.example.com", ObservedStateDigest: "sha256:" + strings.Repeat("c", 64)}
|
||||
}
|
||||
|
||||
func TestIntentValidation(t *testing.T) {
|
||||
if err := validIntent().Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(*Intent)
|
||||
}{
|
||||
{"protocol", func(i *Intent) { i.ProtocolVersion = 2 }},
|
||||
{"host empty", func(i *Intent) { i.HostID = "" }},
|
||||
{"path escape", func(i *Intent) { i.InstanceID = "../git" }},
|
||||
{"shell", func(i *Intent) { i.AppID = "git;id" }},
|
||||
{"long id", func(i *Intent) { i.InstanceID = strings.Repeat("a", 49) }},
|
||||
{"floating tag", func(i *Intent) { i.ImageDigest = "gitea:latest" }},
|
||||
{"package hash", func(i *Intent) { i.PackageDigest = "sha256:xyz" }},
|
||||
{"state missing", func(i *Intent) { i.ObservedStateDigest = "" }},
|
||||
{"url", func(i *Intent) { i.Domain = "https://git.example.com" }},
|
||||
{"wildcard", func(i *Intent) { i.Domain = "*.example.com" }},
|
||||
{"label", func(i *Intent) { i.Domain = "-git.example.com" }},
|
||||
{"empty label", func(i *Intent) { i.Domain = "git..com" }},
|
||||
{"uppercase", func(i *Intent) { i.Domain = "Git.example.com" }},
|
||||
{"ip", func(i *Intent) { i.Domain = "127.0.0.1" }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
i := validIntent()
|
||||
tc.change(&i)
|
||||
if i.Validate() == nil {
|
||||
t.Fatal("accepted invalid intent")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package planner
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const planLifetime = 15 * time.Minute
|
||||
|
||||
// Plan is an offline intent preview, not an executable authorization. Hash binds
|
||||
// its contents but is not a signature. No secret may be added to this structure.
|
||||
type Plan struct {
|
||||
Intent Intent `json:"intent"`
|
||||
ProjectName string `json:"projectName"`
|
||||
DataPath string `json:"dataPath"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func Build(i Intent, now time.Time) (Plan, error) {
|
||||
if err := i.Validate(); err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
now = now.UTC().Truncate(time.Second)
|
||||
p := Plan{Intent: i, ProjectName: "sd-" + i.InstanceID, DataPath: "/var/lib/server-deploy/instances/" + i.InstanceID, CreatedAt: now, ExpiresAt: now.Add(planLifetime)}
|
||||
encoded, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return Plan{}, errors.New("cannot encode plan")
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
p.Hash = "sha256:" + hex.EncodeToString(sum[:])
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Verify checks offline consistency only. Current must be independently inspected
|
||||
// under a host lock before any future write operation uses this comparison.
|
||||
func (p Plan) Verify(current Intent, now time.Time) error {
|
||||
if now.Before(p.CreatedAt) || !now.Before(p.ExpiresAt) {
|
||||
return errors.New("plan is not within its validity window")
|
||||
}
|
||||
if p.Intent != current {
|
||||
return errors.New("plan does not match current intent or observed state")
|
||||
}
|
||||
expected, err := Build(current, p.CreatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.ProjectName != expected.ProjectName || p.DataPath != expected.DataPath || !p.CreatedAt.Equal(expected.CreatedAt) || !p.ExpiresAt.Equal(expected.ExpiresAt) || p.Hash != expected.Hash {
|
||||
return errors.New("plan integrity check failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package planner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildPlan(t *testing.T) {
|
||||
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
|
||||
i := validIntent()
|
||||
p, err := Build(i, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.ProjectName != "sd-git-one" || p.DataPath != "/var/lib/server-deploy/instances/git-one" {
|
||||
t.Fatalf("wrong instance resources: %+v", p)
|
||||
}
|
||||
if p.ExpiresAt.Sub(p.CreatedAt) != 15*time.Minute {
|
||||
t.Fatal("wrong expiration")
|
||||
}
|
||||
if err := p.Verify(i, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, _ := Build(i, now)
|
||||
if again.Hash != p.Hash {
|
||||
t.Fatal("unstable plan hash")
|
||||
}
|
||||
i.InstanceID = "git-two"
|
||||
other, _ := Build(i, now)
|
||||
if other.Hash == p.Hash || other.ProjectName == p.ProjectName || other.DataPath == p.DataPath {
|
||||
t.Fatal("instances share identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsChangedOrExpiredPlan(t *testing.T) {
|
||||
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
|
||||
i := validIntent()
|
||||
original, _ := Build(i, now)
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(*Plan, *Intent, *time.Time)
|
||||
}{
|
||||
{"expired", func(p *Plan, i *Intent, n *time.Time) { *n = p.ExpiresAt }},
|
||||
{"future", func(p *Plan, i *Intent, n *time.Time) { *n = p.CreatedAt.Add(-time.Second) }},
|
||||
{"tampered hash", func(p *Plan, i *Intent, n *time.Time) { p.Hash = "bad" }},
|
||||
{"tampered path", func(p *Plan, i *Intent, n *time.Time) { p.DataPath = "/" }},
|
||||
{"tampered project", func(p *Plan, i *Intent, n *time.Time) { p.ProjectName = "other" }},
|
||||
{"tampered expiry", func(p *Plan, i *Intent, n *time.Time) { p.ExpiresAt = p.ExpiresAt.Add(time.Hour) }},
|
||||
{"state drift", func(p *Plan, i *Intent, n *time.Time) { i.ObservedStateDigest = i.PackageDigest }},
|
||||
{"host drift", func(p *Plan, i *Intent, n *time.Time) { i.HostID = "another-host" }},
|
||||
{"domain drift", func(p *Plan, i *Intent, n *time.Time) { i.Domain = "other.example.com" }},
|
||||
{"intent tamper", func(p *Plan, i *Intent, n *time.Time) { p.Intent.InstanceID = "other" }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p, current, at := original, i, now
|
||||
tc.change(&p, ¤t, &at)
|
||||
if p.Verify(current, at) == nil {
|
||||
t.Fatal("accepted invalid plan")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRejectsInvalidIntent(t *testing.T) {
|
||||
i := validIntent()
|
||||
i.InstanceID = "../bad"
|
||||
if _, err := Build(i, time.Now()); err == nil {
|
||||
t.Fatal("built invalid plan")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user