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
10 changes: 6 additions & 4 deletions UPSTREAM.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@
"cmd/interlock/derive.go": "6c2eda35cdf73d61f22dd3702999390e84c945416a0f699c709bf59ad42246cc",
"cmd/interlock/init.go": "ea4df1ba3bcf0ec2f62cf02752b39598027376096ba974b2e770b0a79dc4ba0f",
"cmd/interlock/install.go": "71cdb44ec7467b0d4e8895eb8ba5d4275ff54e898b74e5d3cb4e66ee55c200ae",
"cmd/interlock/main.go": "5b8b0609026ffb1d3db0c23ece1a116a8d69016102f5f2be937035ffeff310ac",
"cmd/interlock/main.go": "e6b13c3441c625db6705cd5f368feee3c8c84f777976f7d1fb1f1ee3809930b4",
"cmd/interlock/test.go": "e9b680cde45e061155dcc375b057f8ff4e69559d5f2be7dcd15f3685af0e1079",
"cmd/interlock/upgrade.go": "99753fc44d2f1ff98da3c8aaa340d3debcf0341ff333bf12305ad801f4ac484f",
"cmd/interlock/verify.go": "5613c04febf6731fd25a75e615a524d57f99cb762016f458be5243034376b025",
"cmd/interlock/version.go": "262fedc77a86623a48ee5a52940356a399fc71466d6da52f902cd655b9d7303d",
"cmd/interlock/version.go": "3a579f55abf740b427e61932a783bf99203cbbd9d71ee639d782088975069ccf",
"compiler/compiler.go": "50487aaa0c24b3c0d041ab5b9a410fa672de7eb77b1cd3df1ec97cd4cf2bccba",
"compiler/compiler_test.go": "6f80886aa1bfdf79bca014ad47c7b48d2a6bc24c86863afa4ebeb93658a790aa",
"conformance/compat/compat.go": "5db334201f0d701ba1d7cd73916215d08d00bd566ed35477bf331c055edaca37",
Expand Down Expand Up @@ -80,11 +81,12 @@
"derive/schema.go": "4d10bca81110512a10aaf6306d5a5a2ebc4193058b4500cd92022a99b42d4f1e",
"doc.go": "ffda943422fc0104acff178f17f096df5d9d0e9065e598aa0d817c457edfb198",
"docs/concepts/enforcement-model.md": "998939bdf003cc0e192fe68ca30d29e5ad76d4682bfeb14d582d40c478dec15d",
"e2e/coverage_test.go": "ba0df1720714b31a5726964ba33868bd06ecadc346f12e8ee50cfd286d1d23e5",
"e2e/coverage_test.go": "92ed6faa9a698bbcb2045b538fc373bed88bcc5fd3f1ceef1071a845ff6d3d42",
"e2e/e2e_test.go": "28a8d8c7aa3dfa327b615c00454a438264e2b898abc84fe5c0efb9be0a2fdf3f",
"e2e/install_test.go": "a0f65ec294fdc7d032045e214c1f56021f08472f44536b6365df8d66471fd67f",
"e2e/isolation_test.go": "9498039e244184c8ce2460742af70dd93af9e5eada183119344f2d4d5df222e2",
"e2e/parity_test.go": "e44628e98eeee1285a5722ed0ed5193e1c6d927d75eb87f90d3f524d83e65a7a",
"e2e/upgrade_test.go": "273806d579fe3725063d044098a7006f502cf3e1379805abfffed3925905f39c",
"emitspec_test.go": "b669fc73361f275311221ac450008963885801742fe14d47b12e61e677b67545",
"engine/engine.go": "8ee1d012bbf9661288507056c7a015ab9d46fb16e9596d4717b38f15af748b8f",
"engine/engine_test.go": "0291ca081684cd744dec98089a383e1a52093f1c5125a88623dab06d504f73a8",
Expand Down Expand Up @@ -119,7 +121,7 @@
"generator": "operatorstack/interlock:project-upstream",
"schema_version": 1,
"source": {
"commit": "10ef9c03b7e2d83a3564cc302c5eddf4cd8d3ce7",
"commit": "455bc0e2f338eaaec613f9017f4c7079e51622ae",
"path": "labs/21-interlock",
"repository": "operatorstack/intelligence-flow"
}
Expand Down
10 changes: 10 additions & 0 deletions cmd/interlock/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ func main() {
err = cmdInit(os.Args[2:])
case "install":
err = cmdInstall(os.Args[2:])
case "upgrade":
err = cmdUpgrade(os.Args[2:])
case "derive":
err = cmdDerive(os.Args[2:])
case "compile":
Expand Down Expand Up @@ -81,6 +83,7 @@ usage:
interlock init --authoring json [dir] set up a JSON policy (dir defaults to .interlock)
interlock init --authoring go <dir> scaffold a programmable Go policy module
interlock install [ts|python] install the typed client from your registry (--configure-only writes config)
interlock upgrade [--check] update interlock to the latest published version
interlock derive [repo] [--from PATH] [--output DIR] [--review] draft a candidate policy from a repo's existing instructions (never enforces)
interlock test [dir] run the policy's tests (dir defaults to .interlock)
interlock demo [name] narrate a built-in policy (default repository-policy; --list)
Expand Down Expand Up @@ -350,6 +353,13 @@ func cmdDoctor(args []string) error {
} else {
fmt.Printf(" go toolchain : available\n")
}
if latest, lerr := latestVersion(resolveGetHost("")); lerr != nil {
fmt.Printf(" updates : could not check (offline?)\n")
} else if upgradeAvailable(releaseVersion(), latest) {
fmt.Printf(" updates : newer available %s -> %s (run: interlock upgrade)\n", releaseVersion(), latest)
} else {
fmt.Printf(" updates : up to date (latest %s)\n", latest)
}
fmt.Printf(" note : init --authoring json and test need no toolchain\n")
return nil
}
Expand Down
224 changes: 224 additions & 0 deletions cmd/interlock/upgrade.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package main

import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)

// binaryName is the front-door package id and the on-disk executable name — the
// same "interlock" the shell installer uses.
const binaryName = "interlock"

// getBaseURL turns a host into a base URL. A bare host gets https:// (the normal
// case); a host that already carries a scheme is used verbatim, so tests can point
// the CLI at a local http server. Shared by upgrade and the doctor drift check.
func getBaseURL(host string) string {
if strings.Contains(host, "://") {
return strings.TrimRight(host, "/")
}
return "https://" + host
}

func httpGetBytes(url string) ([]byte, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
}
return io.ReadAll(resp.Body)
}

// latestVersion resolves the highest published version from the front door.
func latestVersion(host string) (string, error) {
b, err := httpGetBytes(getBaseURL(host) + "/" + binaryName + "/latest")
if err != nil {
return "", err
}
var payload struct {
Version string `json:"version"`
}
if err := json.Unmarshal(b, &payload); err != nil || payload.Version == "" {
return "", fmt.Errorf("could not parse latest version")
}
return payload.Version, nil
}

// cmpSemver returns >0 if a>b, <0 if a<b, 0 if equal, over the leading x.y.z.
func cmpSemver(a, b string) int {
pa := strings.Split(strings.TrimPrefix(a, "v"), ".")
pb := strings.Split(strings.TrimPrefix(b, "v"), ".")
for i := 0; i < 3; i++ {
var x, y int
if i < len(pa) {
x, _ = strconv.Atoi(pa[i])
}
if i < len(pb) {
y, _ = strconv.Atoi(pb[i])
}
if x != y {
return x - y
}
}
return 0
}

// upgradeAvailable reports whether latest is newer than the running build. A "dev"
// (un-stamped) build always treats a real published version as newer.
func upgradeAvailable(current, latest string) bool {
if current == "dev" || current == "" {
return latest != ""
}
return cmpSemver(latest, current) > 0
}

func cmdUpgrade(args []string) error {
host := ""
checkOnly := false
yes := false
i := 0
for i < len(args) {
switch args[i] {
case "--host":
if i+1 >= len(args) {
return fmt.Errorf("upgrade: --host wants a hostname")
}
host = args[i+1]
i += 2
case "--check":
checkOnly = true
i++
case "--yes", "-y":
yes = true
i++
default:
return fmt.Errorf("upgrade: unexpected argument %q", args[i])
}
}
host = resolveGetHost(host)
current := releaseVersion()

latest, err := latestVersion(host)
if err != nil {
return fmt.Errorf("upgrade: resolve latest: %w", err)
}
if !upgradeAvailable(current, latest) {
fmt.Printf("interlock %s is up to date (latest %s)\n", current, latest)
return nil
}
if checkOnly {
fmt.Printf("a newer interlock is available: %s -> %s (run: interlock upgrade)\n", current, latest)
return nil
}
if runtime.GOOS == "windows" {
return fmt.Errorf("upgrade: automatic upgrade is unix-only; on Windows re-run the install.ps1 installer for %s", latest)
}
if !yes {
fmt.Printf("upgrading interlock %s -> %s\n", current, latest)
}
return downloadAndReplace(host, latest)
}

// downloadAndReplace fetches the OS/arch archive + checksums for version, verifies
// the SHA-256 in-process, and atomically replaces the running executable.
func downloadAndReplace(host, version string) error {
archive := fmt.Sprintf("%s_%s_%s_%s.tar.gz", binaryName, version, runtime.GOOS, runtime.GOARCH)
base := fmt.Sprintf("%s/%s/dl/%s", getBaseURL(host), binaryName, version)

archiveBytes, err := httpGetBytes(base + "/" + archive)
if err != nil {
return fmt.Errorf("upgrade: download %s: %w", archive, err)
}
checksums, err := httpGetBytes(base + "/checksums.txt")
if err != nil {
return fmt.Errorf("upgrade: download checksums: %w", err)
}
want := checksumFor(string(checksums), archive)
if want == "" {
return fmt.Errorf("upgrade: no checksum listed for %s", archive)
}
sum := sha256.Sum256(archiveBytes)
if got := hex.EncodeToString(sum[:]); got != want {
return fmt.Errorf("upgrade: checksum mismatch for %s (want %s, got %s) — refusing", archive, want, got)
}

bin, err := extractBinary(archiveBytes, binaryName)
if err != nil {
return fmt.Errorf("upgrade: %w", err)
}

self, err := os.Executable()
if err != nil {
return err
}
if resolved, rerr := filepath.EvalSymlinks(self); rerr == nil {
self = resolved
}
dir := filepath.Dir(self)
tmp := filepath.Join(dir, "."+binaryName+".upgrade")
if err := os.WriteFile(tmp, bin, 0o755); err != nil {
if os.IsPermission(err) {
return fmt.Errorf("upgrade: cannot write to %s (permission denied). Re-run with sudo, or reinstall: curl -fsSL https://%s/%s | sh", dir, resolveGetHost(host), binaryName)
}
return err
}
if err := os.Rename(tmp, self); err != nil {
os.Remove(tmp)
if os.IsPermission(err) {
return fmt.Errorf("upgrade: cannot replace %s (permission denied). Re-run with sudo, or reinstall: curl -fsSL https://%s/%s | sh", self, resolveGetHost(host), binaryName)
}
return err
}
fmt.Printf("upgraded to interlock %s at %s\n", version, self)
return nil
}

// checksumFor returns the hex digest listed for name in a "sha name" manifest.
func checksumFor(manifest, name string) string {
for _, line := range strings.Split(manifest, "\n") {
fields := strings.Fields(line)
if len(fields) == 2 && fields[1] == name {
return fields[0]
}
}
return ""
}

// extractBinary returns the named regular file from a .tar.gz archive.
func extractBinary(data []byte, name string) ([]byte, error) {
gzr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("open archive: %w", err)
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read archive: %w", err)
}
if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == name {
return io.ReadAll(tr)
}
}
return nil, fmt.Errorf("binary %q not found in archive", name)
}
19 changes: 17 additions & 2 deletions cmd/interlock/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,13 @@ func releaseVersion() string {
// prebuilt-binary install, it reports the exact tag and commit the artifact was
// built from, alongside the wire contracts.
func cmdVersion(args []string) error {
if len(args) > 0 {
return fmt.Errorf("version: unexpected argument %q", args[0])
check := false
for _, a := range args {
if a == "--check" {
check = true
continue
}
return fmt.Errorf("version: unexpected argument %q", a)
}
fmt.Printf("interlock %s (%s)\n", releaseVersion(), buildCommit())
if date != "" {
Expand All @@ -43,5 +48,15 @@ func cmdVersion(args []string) error {
fmt.Printf(" policy protocol : %s\n", ir.Protocol)
fmt.Printf(" effect protocol : %s\n", protocol.EffectRequestProtocol)
fmt.Printf(" receipt schema : %s\n", receipt.Schema)
// --check contacts the front door for a newer release; plain `version` stays offline.
if check {
if latest, err := latestVersion(resolveGetHost("")); err != nil {
fmt.Printf(" updates : could not check (offline?)\n")
} else if upgradeAvailable(releaseVersion(), latest) {
fmt.Printf(" updates : newer available %s -> %s (run: interlock upgrade)\n", releaseVersion(), latest)
} else {
fmt.Printf(" updates : up to date (latest %s)\n", latest)
}
}
return nil
}
1 change: 1 addition & 0 deletions e2e/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
var covered = map[string]string{
"init": "TestJourney_InitTestTamper",
"install": "TestJourney_Install",
"upgrade": "TestJourney_Upgrade",
"derive": "TestJourney_Derive",
"compile": "TestJourney_Derive (promotion) + parity fixtures",
"check": "TestSmoke_InfoCommands",
Expand Down
Loading
Loading