carlos / platform Public

console, hostfloor: console reports are created on S3; executables hashed by streaming (Opus I-3, I-5)

os.IsNotExist does not unwrap S3Store's wrapped not-found, so the console's
status report was never created; errors.Is does. Executable digests stream
through sha256 instead of reading whole binaries under the update unit's
256M limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev febfa4f0b80f4aa3285cdc312fcb4a0090c776e8 parent 38088a7
5 files changed, +62 −10
  • internal/console/hostreports.go +12 −6
  • internal/console/hostreports_put_test.go +35 −0
  • internal/hostfloor/disk.go +1 −2
  • internal/hostfloor/floor.go +11 −0
  • internal/hostfloor/image_linux.go +3 −2

CI passed — run details

diff --git a/internal/console/hostreports.go b/internal/console/hostreports.go
index b56aaf512..10ca77591 100644
--- a/internal/console/hostreports.go
+++ b/internal/console/hostreports.go
@@ -3,6 +3,7 @@ package console
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostfloor"
@@ -65,12 +66,7 @@ func (s *Server) StartHostReports(ctx context.Context) {
if err == nil {
key := "status/consoles/" + s.cfg.HostConsoleID + ".json"
raw, _ := json.Marshal(report)
- _, etag, readErr := s.cfg.Releases.Get(ctx, key)
- if readErr != nil && !os.IsNotExist(readErr) {
- err = readErr
- } else {
- _, err = s.cfg.Releases.Put(ctx, key, raw, etag)
- }
+ err = putHostReport(ctx, s.cfg.Releases, key, raw)
}
if err == nil && len(s.cfg.Key) != 0 {
d := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket}
@@ -90,3 +86,13 @@ func (s *Server) StartHostReports(ctx context.Context) {
}
}
}
+
+// putHostReport replaces the console's report, creating it when absent.
+func putHostReport(ctx context.Context, store release.Store, key string, raw []byte) error {
+ _, etag, err := store.Get(ctx, key)
+ if err != nil && !errors.Is(err, os.ErrNotExist) { // S3Store wraps it
+ return err
+ }
+ _, err = store.Put(ctx, key, raw, etag)
+ return err
+}
diff --git a/internal/console/hostreports_put_test.go b/internal/console/hostreports_put_test.go
new file mode 100644
index 000000000..a21386d8f
--- /dev/null
+++ b/internal/console/hostreports_put_test.go
@@ -0,0 +1,35 @@
+package console
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// s3ShapedStore wraps Get's errors the way S3Store does.
+type s3ShapedStore struct{ release.Store }
+
+func (s s3ShapedStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ b, etag, err := s.Store.Get(ctx, key)
+ if err != nil {
+ return nil, "", fmt.Errorf("s3store: %s: %w", key, err)
+ }
+ return b, etag, nil
+}
+
+func TestPutHostReportCreatesThroughAWrappedNotFound(t *testing.T) {
+ ctx := t.Context()
+ store := s3ShapedStore{release.NewDirStore(t.TempDir())}
+ const key = "status/consoles/console-2.json"
+ for _, body := range []string{`{"n":1}`, `{"n":2}`} {
+ if err := putHostReport(ctx, store, key, []byte(body)); err != nil {
+ t.Fatalf("put %s: %v", body, err)
+ }
+ got, _, err := store.Get(ctx, key)
+ if err != nil || string(got) != body {
+ t.Fatalf("stored %q, %v; want %s", got, err, body)
+ }
+ }
+}
diff --git a/internal/hostfloor/disk.go b/internal/hostfloor/disk.go
index 233286c22..acc0bfab6 100644
--- a/internal/hostfloor/disk.go
+++ b/internal/hostfloor/disk.go
@@ -107,11 +107,10 @@ func (d Disk) VerifyFile(f *os.File, platform string, roles ...string) (string,
if _, err = f.Seek(0, io.SeekStart); err != nil {
return "", err
}
- raw, err := io.ReadAll(io.LimitReader(f, 512<<20))
+ hash, err := DigestReader(io.LimitReader(f, 512<<20))
if err != nil {
return "", err
}
- hash := Digest(raw)
entries, err := os.ReadDir(filepath.Join(d.Dir, "identities"))
if err != nil {
return "", err
diff --git a/internal/hostfloor/floor.go b/internal/hostfloor/floor.go
index 7fa001489..2d9d99bb7 100644
--- a/internal/hostfloor/floor.go
+++ b/internal/hostfloor/floor.go
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"github.com/carlosframework/platform/internal/release"
+ "io"
)
const Key = "control/moves-protocol.json"
@@ -40,6 +41,16 @@ type Verified struct {
func Digest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) }
+// DigestReader is Digest over a stream, so an executable is hashed without
+// holding it in memory (the update unit runs under MemoryMax=256M).
+func DigestReader(r io.Reader) (string, error) {
+ h := sha256.New()
+ if _, err := io.Copy(h, r); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(h.Sum(nil)), nil
+}
+
func Verify(pub ed25519.PublicKey, bucket string, raw, auth []byte) (Verified, error) {
var f Floor
var a Authorization
diff --git a/internal/hostfloor/image_linux.go b/internal/hostfloor/image_linux.go
index c5687f37b..b048ac82e 100644
--- a/internal/hostfloor/image_linux.go
+++ b/internal/hostfloor/image_linux.go
@@ -79,9 +79,10 @@ func ExecutingHash() (string, error) {
return "", err
}
defer f.Close()
- raw, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", f.Fd()))
+ image, err := os.Open(fmt.Sprintf("/proc/self/fd/%d", f.Fd()))
if err != nil {
return "", err
}
- return Digest(raw), nil
+ defer image.Close()
+ return DigestReader(image)
}