package appconfig import ( "context" "crypto/ecdh" "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "path/filepath" "time" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // ErrNotHeld is RenderHeld refusing a route it does not own: a row SyncExec // converges (status "" or "ready"), a row held by another operation, a row // of a shape no move takes, or a call without the op and box the ack is // bound to. var ErrNotHeld = errors.New("config: not a held row of this move") // ErrHeldClaimWired is RenderHeld refusing to write while CARLOS_HOST_CLAIM // is on (ruling F10): a held row's write has no claim selection until Plan // 2 adds one under the landing op, and an unguarded write is not added. var ErrHeldClaimWired = errors.New("config: held rows are not rendered while CARLOS_HOST_CLAIM is on") // HeldRender is what RenderHeld rendered: the file, the sha256 of its // bytes, and the versions of the objects merged into it. BundleETags has // the keys "default" and "environment". type HeldRender struct { Path string SHA256 string BundleETags map[string]string OverlayETag string } // RenderHeld renders the config file of a held row: one that a move holds // on this box (status not "" or "ready", hold_op empty or op), which // SyncExec therefore skips (execRouteBase), so the two never write one file. // It merges through LoadMergedHostVersions, writes through the same writer // SyncExec uses (renderExecConfig) when the bytes differ, and writes a // move-bound ack through WriteConfigAck on every call, changed or not: the // ack names op, the file's sha256 and the merged objects' etags, which T3 // compares (moves design ยง9.1). // // It writes through legacyHostWork, so it refuses outright while // CARLOS_HOST_CLAIM is on (ErrHeldClaimWired) and writes nothing. func RenderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string) (HeldRender, error) { if hostwrite.Enabled(os.Getenv) { return HeldRender{}, ErrHeldClaimWired } return renderHeld(ctx, s, priv, dataDir, rt, op, box, func(HeldRender) (hostwrite.Work, error) { return legacyHostWork{}, nil }, nil) } // RenderHeldBefore is RenderHeld with before called once the rendered bytes // are known and before anything is written: before receives the hex sha256 // of exactly the bytes the render then places (or finds already in place), // so a caller can make its ownership of the file durable first (moves Plan 5 // Task 9a, carry (c)). An error from before ends the render: nothing is // written and no ack is sent. The write itself is RenderHeld's, unchanged. func RenderHeldBefore(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string, before func(sha256hex string) error) (HeldRender, error) { if before == nil { return HeldRender{}, hostwrite.ErrUnavailable } if hostwrite.Enabled(os.Getenv) { return HeldRender{}, ErrHeldClaimWired } return renderHeld(ctx, s, priv, dataDir, rt, op, box, func(HeldRender) (hostwrite.Work, error) { return legacyHostWork{}, nil }, before) } // RenderHeldUnder is RenderHeld for a held row under the host claim (moves // Phase 1 Plan 3 Task 5, ruling H13): the same ownership check, merge, // renderer, writer and move-bound ack, with the file written through the // claim selection work returns instead of legacyHostWork. work is called // once the merged versions are known, with BundleETags and OverlayETag set // (the rest of the HeldRender empty), so its caller can select a held-config // work bound to exactly the etags this render writes; nothing is written // before it returns. A nil work, or a work that is nil, is refused. It does // not consult CARLOS_HOST_CLAIM: the selection is the authority. func RenderHeldUnder(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string, work func(HeldRender) (hostwrite.Work, error)) (HeldRender, error) { if work == nil { return HeldRender{}, hostwrite.ErrUnavailable } return renderHeld(ctx, s, priv, dataDir, rt, op, box, work, nil) } // RenderHeldUnderBefore is RenderHeldUnder with RenderHeldBefore's before // (moves after claim Plan 12, Task 7; platform-1e's answer to Q2.2): the // held render the landing's configured step runs under the claim, which // keeps carry (c). The ownership checks, the merge, the renderer, the // writer and the move-bound ack are RenderHeld's. work is called once the // merged versions are known, as for RenderHeldUnder; before is then called // with the hex sha256 of exactly the bytes the render places (or finds in // place), and only after it returns is anything written, through the // selection work returned. An error from before ends the render: nothing is // written, the selection is never entered and no ack is sent. A nil work or // a nil before is refused before anything is read. Like RenderHeldUnder it // does not consult CARLOS_HOST_CLAIM: the selection is the authority. func RenderHeldUnderBefore(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string, work func(HeldRender) (hostwrite.Work, error), before func(sha256hex string) error) (HeldRender, error) { if work == nil || before == nil { return HeldRender{}, hostwrite.ErrUnavailable } return renderHeld(ctx, s, priv, dataDir, rt, op, box, work, before) } func renderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string, work func(HeldRender) (hostwrite.Work, error), before func(string) error) (HeldRender, error) { if err := heldRenderable(rt, op, box); err != nil { return HeldRender{}, err } acct := release.RouteAccount(rt) m, v, err := LoadMergedHostVersions(ctx, s, acct, rt.App, rt.Environment, rt.Host) if err != nil { return HeldRender{}, err } w, err := work(HeldRender{BundleETags: map[string]string{"default": v.Default, "environment": v.Environment}, OverlayETag: v.Overlay}) if err != nil { return HeldRender{}, err } if w == nil { return HeldRender{}, hostwrite.ErrUnavailable } r, err := renderExecConfigBefore(ctx, w, rt, m, priv, dataDir, "", v.Overlay, before, false) if err != nil { return HeldRender{}, err } sum := sha256.Sum256(r.rendered) out := HeldRender{ Path: r.path, SHA256: hex.EncodeToString(sum[:]), BundleETags: map[string]string{"default": v.Default, "environment": v.Environment}, OverlayETag: v.Overlay, } a := ConfigAck{Schema: ConfigAckSchema, Box: box, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), InstanceConfigETag: v.Overlay, MoveOp: op, FileSHA256: out.SHA256, BundleETags: map[string]string{"default": v.Default, "environment": v.Environment}} if suffixes, err := relaySuffixesFromRelayVar(m, priv, routeBinding(rt)); err != nil { fmt.Printf(" %s: held config rendered, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err) } else { a.RelaySuffixes = suffixes } if err := WriteConfigAck(ctx, s, a); err != nil { return out, fmt.Errorf("%s: held config ack: %w", rt.Host, err) } return out, nil } // heldRenderable is RenderHeld's admission: the exec-instance shape a move // takes (execSyncable without its status clause), a status SyncExec skips, // no hold by another operation, a bare host name, and an op and box to bind // the ack to. func heldRenderable(rt registry.Route, op, box string) error { switch { case op == "" || box == "": return fmt.Errorf("%w: a held render needs a move op and a box", ErrNotHeld) case rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host): return fmt.Errorf("%w: %q is not a bare host name", ErrNotHeld, rt.Host) case rt.Status == "" || rt.Status == "ready": return fmt.Errorf("%w: %s is %q, which config sync owns", ErrNotHeld, rt.Host, rt.Status) case rt.HoldOp != "" && rt.HoldOp != op: return fmt.Errorf("%w: %s is held by %s", ErrNotHeld, rt.Host, rt.HoldOp) case rt.Kind != "instance" || rt.App == "" || rt.Platform || rt.Backing == registry.BackingUnit || rt.Socket == "": return fmt.Errorf("%w: %s is not an exec-backed instance", ErrNotHeld, rt.Host) } return nil }