Files

57 lines
1.8 KiB
Go

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
}