Merge main
This commit is contained in:
@@ -24,6 +24,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -202,6 +203,7 @@ func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publis
|
|||||||
// not after a clone that then fails at npm ci.
|
// not after a clone that then fails at npm ci.
|
||||||
built, err = builder.Build(ctx, builder.Command, publisher,
|
built, err = builder.Build(ctx, builder.Command, publisher,
|
||||||
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
||||||
|
forgeFrom(),
|
||||||
func(step, message string) {
|
func(step, message string) {
|
||||||
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
|
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
|
||||||
})
|
})
|
||||||
@@ -367,6 +369,53 @@ func packagesFrom() (builder.Npmrc, error) {
|
|||||||
return builder.Npmrc{Scope: scope, Registry: registry, Token: secret}, nil
|
return builder.Npmrc{Scope: scope, Registry: registry, Token: secret}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// forgeFrom is the git credential this builder may offer a clone, composed from the same binding
|
||||||
|
// and sealed secret its package-registry half already reads: the forge that answers npm is the
|
||||||
|
// forge that hosts the repositories, and its provisioner applies one password to one user for
|
||||||
|
// both. Anything missing means no credential, and every clone stays anonymous — which is all a
|
||||||
|
// mesh of public repositories ever needs.
|
||||||
|
//
|
||||||
|
// The URL names the binding's own address — the machine the mesh says the forge is on — so a
|
||||||
|
// private repository is registered and built by that address, and a clone of anything else is
|
||||||
|
// never shown this credential (git's credential store matches the whole origin).
|
||||||
|
func forgeFrom() builder.GitCredential {
|
||||||
|
path := strings.TrimSpace(os.Getenv("MESH_PACKAGE_BINDING"))
|
||||||
|
if path == "" {
|
||||||
|
return builder.GitCredential{}
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return builder.GitCredential{}
|
||||||
|
}
|
||||||
|
var told struct {
|
||||||
|
At string `json:"at"`
|
||||||
|
As string `json:"as"`
|
||||||
|
Serves map[string]any `json:"serves"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &told); err != nil || told.At == "" || told.As == "" {
|
||||||
|
return builder.GitCredential{}
|
||||||
|
}
|
||||||
|
secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN"))
|
||||||
|
if file := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN_FILE")); file != "" {
|
||||||
|
if raw, err := os.ReadFile(file); err == nil {
|
||||||
|
secret = strings.TrimSpace(string(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
return builder.GitCredential{}
|
||||||
|
}
|
||||||
|
scheme := "https"
|
||||||
|
if s, ok := told.Serves["scheme"]; ok {
|
||||||
|
scheme = fmt.Sprintf("%v", s)
|
||||||
|
}
|
||||||
|
host := told.At
|
||||||
|
if port, ok := told.Serves["port"]; ok {
|
||||||
|
host = fmt.Sprintf("%s:%v", told.At, port)
|
||||||
|
}
|
||||||
|
made := url.URL{Scheme: scheme, User: url.UserPassword(told.As, secret), Host: host}
|
||||||
|
return builder.GitCredential{URL: made.String()}
|
||||||
|
}
|
||||||
|
|
||||||
func short(commit string) string {
|
func short(commit string) string {
|
||||||
if len(commit) > 8 {
|
if len(commit) > 8 {
|
||||||
return commit[:8]
|
return commit[:8]
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ func buildOnce(ctx context.Context, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
built, buildErr := builder.Build(ctx, builder.Command, publisher, repository, *path, *ref, where, bases, npmrc,
|
built, buildErr := builder.Build(ctx, builder.Command, publisher, repository, *path, *ref, where, bases, npmrc,
|
||||||
|
forgeFrom(),
|
||||||
func(step, message string) { fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message) })
|
func(step, message string) { fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message) })
|
||||||
if buildErr != nil {
|
if buildErr != nil {
|
||||||
return buildErr
|
return buildErr
|
||||||
|
|||||||
@@ -46,25 +46,35 @@ func buildCommand(ctx context.Context, args []string) error {
|
|||||||
// retype each repository is asking them to be the loop. Naming a repository and asking which
|
// retype each repository is asking them to be the loop. Naming a repository and asking which
|
||||||
// ones need building are different requests, so they are not combined.
|
// ones need building are different requests, so they are not combined.
|
||||||
behind := set.Bool("behind", false, "every module the mesh holds older than its source has")
|
behind := set.Bool("behind", false, "every module the mesh holds older than its source has")
|
||||||
|
// A repository on the mesh's own forge, named by its path there (novox/hq ADR 0111). Without it
|
||||||
|
// the repository is external, cloned exactly as given — see source.go.
|
||||||
|
self := set.Bool("self", false, "the repository is a path on the forge holding the git seat")
|
||||||
positionals, err := parseAround(set, args)
|
positionals, err := parseAround(set, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if *behind {
|
if *behind {
|
||||||
if len(positionals) != 0 {
|
if len(positionals) != 0 || *self {
|
||||||
return errors.New("build <repository> or build --behind, not both: one names a " +
|
return errors.New("build <repository> or build --behind, not both: one names a " +
|
||||||
"repository and the other asks which need building")
|
"repository and the other asks which need building")
|
||||||
}
|
}
|
||||||
return buildBehind(ctx, *wait)
|
return buildBehind(ctx, *wait)
|
||||||
}
|
}
|
||||||
if len(positionals) != 1 {
|
if len(positionals) != 1 {
|
||||||
return errors.New("build <repository> [--ref R] [--wait D] [--dry-run]")
|
return errors.New("build <repository> [--self] [--path P] [--ref R] [--wait D] [--dry-run]")
|
||||||
|
}
|
||||||
|
source := buildSource{Repository: positionals[0]}
|
||||||
|
if *self {
|
||||||
|
if err := onASeat(source.Repository); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
source.Seat = gitSeat
|
||||||
}
|
}
|
||||||
|
|
||||||
if *dryRun {
|
if *dryRun {
|
||||||
return buildAndShow(ctx, positionals[0], *path, *ref, *wait)
|
return buildAndShow(ctx, source, *path, *ref, *wait)
|
||||||
}
|
}
|
||||||
return buildOne(ctx, positionals[0], *path, *ref, *wait)
|
return buildOne(ctx, source, *path, *ref, *wait)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFrom turns what a builder said into what the mesh keeps.
|
// buildFrom turns what a builder said into what the mesh keeps.
|
||||||
@@ -325,7 +335,8 @@ func buildBehind(ctx context.Context, wait time.Duration) error {
|
|||||||
// Its own recorded ref, not its head commit: a module tracking a branch should be built
|
// Its own recorded ref, not its head commit: a module tracking a branch should be built
|
||||||
// from that branch, and pinning to the commit the mesh happened to notice would quietly
|
// from that branch, and pinning to the commit the mesh happened to notice would quietly
|
||||||
// turn a tracked branch into a pin.
|
// turn a tracked branch into a pin.
|
||||||
if err := buildOne(ctx, e.Source.Repository, e.Source.Path, e.Source.Ref, wait); err != nil {
|
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
||||||
|
if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, wait); err != nil {
|
||||||
fmt.Printf(" %v\n", err)
|
fmt.Printf(" %v\n", err)
|
||||||
failed = append(failed, e.Manifest.Module)
|
failed = append(failed, e.Manifest.Module)
|
||||||
}
|
}
|
||||||
@@ -343,7 +354,14 @@ func buildBehind(ctx context.Context, wait time.Duration) error {
|
|||||||
// buildOne asks a build machine for one repository and records everything that came back.
|
// buildOne asks a build machine for one repository and records everything that came back.
|
||||||
//
|
//
|
||||||
// Separated from the command so `--behind` can walk a list without a second path to the same act.
|
// Separated from the command so `--behind` can walk a list without a second path to the same act.
|
||||||
func buildOne(ctx context.Context, repository, path, ref string, wait time.Duration) error {
|
func buildOne(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error {
|
||||||
|
// Before anything is asked of a builder: a source on a seat nobody holds is refused here, with
|
||||||
|
// the reason, rather than sent to a machine to fail at `git clone`.
|
||||||
|
repository, err := cloneFrom(ctx, source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
ident, err := openIdentity(ctx)
|
ident, err := openIdentity(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -365,7 +383,10 @@ func buildOne(ctx context.Context, repository, path, ref string, wait time.Durat
|
|||||||
Ref: ref,
|
Ref: ref,
|
||||||
Held: heldBy(ctx),
|
Held: heldBy(ctx),
|
||||||
}
|
}
|
||||||
fmt.Printf("asked for %s", request.Repository)
|
fmt.Printf("asked for %s", source)
|
||||||
|
if source.Seat != "" {
|
||||||
|
fmt.Printf(" (%s)", repository)
|
||||||
|
}
|
||||||
if path != "" {
|
if path != "" {
|
||||||
fmt.Printf(" at %s", path)
|
fmt.Printf(" at %s", path)
|
||||||
}
|
}
|
||||||
@@ -414,11 +435,18 @@ func buildOne(ctx context.Context, repository, path, ref string, wait time.Durat
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Recorded with where it came from, so "is this current?" is answerable without building it
|
// Recorded with where it came from, so "is this current?" is answerable without building it
|
||||||
// again (novox/hq ADR 0009).
|
// again (novox/hq ADR 0009). **For a source on a seat, as the path and the seat, never the URL
|
||||||
if err := inv.RegisterModule(ctx, manifest, inventory.Source{
|
// just cloned** (ADR 0111): the URL is where the forge runs today, and recording it would put
|
||||||
|
// the forge's address back into every module built from it. The build log above keeps the URL,
|
||||||
|
// because that is what was cloned.
|
||||||
|
recorded := inventory.Source{
|
||||||
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
||||||
BuiltFrom: result.Commit, Head: result.Commit,
|
BuiltFrom: result.Commit, Head: result.Commit,
|
||||||
}); err != nil {
|
}
|
||||||
|
if source.Seat != "" {
|
||||||
|
recorded.Repository, recorded.Seat = source.Repository, source.Seat
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("\n%s %s, built on %s from %s\n",
|
fmt.Printf("\n%s %s, built on %s from %s\n",
|
||||||
@@ -428,7 +456,11 @@ func buildOne(ctx context.Context, repository, path, ref string, wait time.Durat
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildAndShow builds and prints the manifest without recording anything.
|
// buildAndShow builds and prints the manifest without recording anything.
|
||||||
func buildAndShow(ctx context.Context, repository, path, ref string, wait time.Duration) error {
|
func buildAndShow(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error {
|
||||||
|
repository, err := cloneFrom(ctx, source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
ident, err := openIdentity(ctx)
|
ident, err := openIdentity(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ func run() error {
|
|||||||
return planCommand(ctx, args[1:])
|
return planCommand(ctx, args[1:])
|
||||||
case "push":
|
case "push":
|
||||||
return pushCommand(ctx, args[1:])
|
return pushCommand(ctx, args[1:])
|
||||||
|
case "seats":
|
||||||
|
return seatsCommand(ctx, args[1:])
|
||||||
case "status":
|
case "status":
|
||||||
return statusCommand(ctx, args[1:])
|
return statusCommand(ctx, args[1:])
|
||||||
case "version":
|
case "version":
|
||||||
@@ -157,6 +159,7 @@ func usage() {
|
|||||||
upgrade <name> roll-out [--together] ...send it to the machines running it
|
upgrade <name> roll-out [--together] ...send it to the machines running it
|
||||||
upgrade <name> record ...record that they are behind, and send nothing
|
upgrade <name> record ...record that they are behind, and send nothing
|
||||||
status [--json] what is wrong, what is quiet, and what is out of date
|
status [--json] what is wrong, what is quiet, and what is out of date
|
||||||
|
seats [--json] every seat this mesh defines, what it delivers, and who holds it
|
||||||
board [--listen ADDR] the same three questions, as a page that holds nothing
|
board [--listen ADDR] the same three questions, as a page that holds nothing
|
||||||
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
||||||
assign <node> <module> put a module on a node
|
assign <node> <module> put a module on a node
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
}
|
}
|
||||||
|
|
||||||
offered := map[string][]catalogue.Provider{}
|
offered := map[string][]catalogue.Provider{}
|
||||||
|
var firstHeld []catalogue.Held
|
||||||
for _, o := range others {
|
for _, o := range others {
|
||||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true})
|
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -224,6 +225,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
// report, and nothing of theirs is running, so it offers nothing.
|
// report, and nothing of theirs is running, so it offers nothing.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
firstHeld = append(firstHeld, got.Claims...)
|
||||||
for _, m := range got.Modules {
|
for _, m := range got.Modules {
|
||||||
for _, name := range m.OffersAt(catalogue.ScopeMesh) {
|
for _, name := range m.OffersAt(catalogue.ScopeMesh) {
|
||||||
// What that module says a consumer needs to know, with that node's settings on
|
// What that module says a consumer needs to know, with that node's settings on
|
||||||
@@ -234,7 +236,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
return catalogue.World{}, err
|
return catalogue.World{}, err
|
||||||
}
|
}
|
||||||
offered[name] = append(offered[name], catalogue.Provider{
|
offered[name] = append(offered[name], catalogue.Provider{
|
||||||
Node: o.node.Name, At: o.node.At, Serves: serves})
|
Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,14 +246,20 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
world := catalogue.World{Offered: offered}
|
// **The second pass is given the first pass's holdings.** A seat's holder answers a requirement
|
||||||
|
// with several providers (novox/hq ADR 0110), so a node consuming one resolves only once the
|
||||||
|
// holder is known. Without them its set is refused here, and a refused node's own claims drop
|
||||||
|
// out of what the mesh holds — so a second holder of one of its seats would pass unrefused.
|
||||||
|
world := catalogue.World{Offered: offered, Held: firstHeld}
|
||||||
|
var held []catalogue.Held
|
||||||
for _, o := range others {
|
for _, o := range others {
|
||||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, world)
|
got, err := catalogue.Resolve(shelf, o.assigned, o.node, world)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
world.Held = append(world.Held, got.Claims...)
|
held = append(held, got.Claims...)
|
||||||
}
|
}
|
||||||
|
world.Held = held
|
||||||
return world, nil
|
return world, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,6 +808,22 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listensLines is what a person is told about what this module would open, and why — the same
|
||||||
|
// `why` every listens entry already carries for the firewall it also feeds (novox/hq ADR 0007), so
|
||||||
|
// deciding whether to assign a module can see what it would open before it opens it, not only
|
||||||
|
// after. A module with nothing to listen on prints nothing extra, same as today.
|
||||||
|
func listensLines(m catalogue.Manifest) []string {
|
||||||
|
var out []string
|
||||||
|
for _, l := range m.Listens {
|
||||||
|
if l.Why == "" {
|
||||||
|
out = append(out, fmt.Sprintf(" listens %d/%s from %s", l.Port, l.At(), l.From))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, fmt.Sprintf(" listens %d/%s from %s — %s", l.Port, l.At(), l.From, l.Why))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func planCommand(ctx context.Context, args []string) error {
|
func planCommand(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("plan", flag.ContinueOnError)
|
set := flag.NewFlagSet("plan", flag.ContinueOnError)
|
||||||
// Because "one resource" does not tell you whether the settings landed. Being able to read
|
// Because "one resource" does not tell you whether the settings landed. Being able to read
|
||||||
@@ -853,6 +877,9 @@ func planCommand(ctx context.Context, args []string) error {
|
|||||||
fmt.Printf("%s would run:\n", args[0])
|
fmt.Printf("%s would run:\n", args[0])
|
||||||
for _, m := range plan.Modules {
|
for _, m := range plan.Modules {
|
||||||
fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module])
|
fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module])
|
||||||
|
for _, line := range listensLines(m) {
|
||||||
|
fmt.Println(line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// What was assigned here and cannot run here. Said with the rest rather than as a refusal: it is
|
// What was assigned here and cannot run here. Said with the rest rather than as a refusal: it is
|
||||||
// one module on the wrong machine, the others still run, and the remedy is to move this one.
|
// one module on the wrong machine, the others still run, and the remedy is to move this one.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// `plan` tells a person what a module would open and why, from the same `why` every listens
|
||||||
|
// entry already carries for the firewall (novox/hq ADR 0007) — so deciding whether to assign a
|
||||||
|
// module does not need reading its manifest first.
|
||||||
|
func TestListensLinesShowWhatAModuleWouldOpenAndWhy(t *testing.T) {
|
||||||
|
m := catalogue.Manifest{Module: "minio", Listens: []catalogue.Listening{
|
||||||
|
{Port: 9000, From: catalogue.FromMesh, Why: "the S3 endpoint"},
|
||||||
|
{Port: 9001, From: catalogue.FromMesh},
|
||||||
|
}}
|
||||||
|
got := listensLines(m)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("two listens entries, got %d: %v", len(got), got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[0], "9000/tcp") || !strings.Contains(got[0], "the S3 endpoint") {
|
||||||
|
t.Errorf("the port and its why did not both appear: %q", got[0])
|
||||||
|
}
|
||||||
|
if strings.Contains(got[1], "—") {
|
||||||
|
t.Errorf("a listens entry with no why should not print a dash: %q", got[1])
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[1], "9001/tcp") {
|
||||||
|
t.Errorf("the port still appears without a why: %q", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListensLinesAreEmptyForAModuleWithNothingToListenOn(t *testing.T) {
|
||||||
|
if got := listensLines(catalogue.Manifest{Module: "board"}); len(got) != 0 {
|
||||||
|
t.Errorf("a module with no listens should print nothing, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What this mesh can have one of, and who fills each (novox/hq ADR 0110).
|
||||||
|
//
|
||||||
|
// **Derived every time, never stored.** A seat is held by a module assignment, so the answer is
|
||||||
|
// computed from assignments by the same resolution that decides what every machine runs. A table
|
||||||
|
// of holders kept beside the assignments would be a second copy of one fact, and the first thing
|
||||||
|
// to be wrong about it.
|
||||||
|
|
||||||
|
// seatHolder is one assignment holding a seat.
|
||||||
|
type seatHolder struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatRow is one seat and who holds it. Unheld is an answer — "this mesh has no X" — not a fault.
|
||||||
|
type seatRow struct {
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Delivers string `json:"delivers,omitempty"`
|
||||||
|
Decision string `json:"decision"`
|
||||||
|
Holders []seatHolder `json:"holders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatsHeld is every seat the mesh defines with its holders, and every claim held that names no
|
||||||
|
// seat in the set.
|
||||||
|
//
|
||||||
|
// **The second list is not empty by construction.** Manifests are held to the set when they are
|
||||||
|
// registered, and a mesh can hold one registered before the set closed. Leaving its claim out of the
|
||||||
|
// overview would make the one thing the overview is for — what does this mesh have — quietly
|
||||||
|
// incomplete.
|
||||||
|
func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []catalogue.Held) {
|
||||||
|
defined := map[string]bool{}
|
||||||
|
rows := make([]seatRow, 0, len(seats))
|
||||||
|
for _, s := range seats {
|
||||||
|
defined[s.Name] = true
|
||||||
|
row := seatRow{Seat: s.Name, Scope: s.Scope, Delivers: s.Delivers, Decision: s.Decision,
|
||||||
|
Holders: []seatHolder{}}
|
||||||
|
seen := map[seatHolder]bool{}
|
||||||
|
for _, h := range held {
|
||||||
|
if h.Claim != s.Name || h.Scope != s.Scope {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
holder := seatHolder{Node: h.Node, Module: h.Module}
|
||||||
|
if !seen[holder] {
|
||||||
|
seen[holder] = true
|
||||||
|
row.Holders = append(row.Holders, holder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(row.Holders, func(i, j int) bool {
|
||||||
|
if row.Holders[i].Node != row.Holders[j].Node {
|
||||||
|
return row.Holders[i].Node < row.Holders[j].Node
|
||||||
|
}
|
||||||
|
return row.Holders[i].Module < row.Holders[j].Module
|
||||||
|
})
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
var outside []catalogue.Held
|
||||||
|
for _, h := range held {
|
||||||
|
if !defined[h.Claim] {
|
||||||
|
outside = append(outside, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(outside, func(i, j int) bool {
|
||||||
|
if outside[i].Claim != outside[j].Claim {
|
||||||
|
return outside[i].Claim < outside[j].Claim
|
||||||
|
}
|
||||||
|
return outside[i].Node < outside[j].Node
|
||||||
|
})
|
||||||
|
return rows, outside
|
||||||
|
}
|
||||||
|
|
||||||
|
func seatsCommand(ctx context.Context, args []string) error {
|
||||||
|
set := flag.NewFlagSet("seats", flag.ContinueOnError)
|
||||||
|
asJSON := set.Bool("json", false, "the same, as JSON")
|
||||||
|
if err := set.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
inv := open.inventory
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Every node, none excluded: the same view of what each machine holds that planning uses.
|
||||||
|
world, err := theRestOfTheMesh(ctx, inv, shelf, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, outside := seatsHeld(catalogue.Seats(), world.Held)
|
||||||
|
|
||||||
|
if *asJSON {
|
||||||
|
out := struct {
|
||||||
|
Seats []seatRow `json:"seats"`
|
||||||
|
Outside []catalogue.Held `json:"outside,omitempty"`
|
||||||
|
}{rows, outside}
|
||||||
|
body, err := json.MarshalIndent(out, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(body))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "SEAT\tSCOPE\tDELIVERS\tHELD BY")
|
||||||
|
for _, r := range rows {
|
||||||
|
delivers := r.Delivers
|
||||||
|
if delivers == "" {
|
||||||
|
delivers = "—"
|
||||||
|
}
|
||||||
|
holders := "unheld"
|
||||||
|
if len(r.Holders) > 0 {
|
||||||
|
parts := make([]string, 0, len(r.Holders))
|
||||||
|
for _, h := range r.Holders {
|
||||||
|
parts = append(parts, h.Module+" on "+h.Node)
|
||||||
|
}
|
||||||
|
holders = strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Seat, r.Scope, delivers, holders)
|
||||||
|
}
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(outside) > 0 {
|
||||||
|
fmt.Println("\nheld, and not a seat this mesh defines (registered before the set closed — novox/hq ADR 0110):")
|
||||||
|
for _, h := range outside {
|
||||||
|
fmt.Printf(" %s %s on %s\n", h.Claim, h.Module, h.Node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The overview of what a mesh has (novox/hq ADR 0110).
|
||||||
|
|
||||||
|
func TestEverySeatIsListedIncludingTheOnesNobodyHolds(t *testing.T) {
|
||||||
|
// An unheld seat is an answer — "this mesh has no forge" — so it is listed rather than omitted.
|
||||||
|
rows, _ := seatsHeld(catalogue.Seats(), []catalogue.Held{
|
||||||
|
{Claim: "mesh-store", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "postgres"},
|
||||||
|
})
|
||||||
|
if len(rows) != len(catalogue.Seats()) {
|
||||||
|
t.Fatalf("%d seats listed of %d", len(rows), len(catalogue.Seats()))
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
switch r.Seat {
|
||||||
|
case "mesh-store":
|
||||||
|
if len(r.Holders) != 1 || r.Holders[0].Module != "postgres" || r.Holders[0].Node != "anchor" {
|
||||||
|
t.Errorf("mesh-store is held by %+v", r.Holders)
|
||||||
|
}
|
||||||
|
if r.Delivers != "postgres-database" {
|
||||||
|
t.Errorf("mesh-store does not say what it delivers: %q", r.Delivers)
|
||||||
|
}
|
||||||
|
case "git":
|
||||||
|
if len(r.Holders) != 0 {
|
||||||
|
t.Errorf("git is held by %+v in a mesh with no forge", r.Holders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANodeSeatListsEveryMachineHoldingIt(t *testing.T) {
|
||||||
|
rows, _ := seatsHeld(catalogue.Seats(), []catalogue.Held{
|
||||||
|
{Claim: "the-packet-filter", Scope: catalogue.ScopeNode, Node: "node2", Module: "nftables"},
|
||||||
|
{Claim: "the-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"},
|
||||||
|
// Resolved twice, reported once: a machine is one holder however many passes saw it.
|
||||||
|
{Claim: "the-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"},
|
||||||
|
})
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Seat != "the-packet-filter" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(r.Holders) != 2 || r.Holders[0].Node != "anchor" || r.Holders[1].Node != "node2" {
|
||||||
|
t.Fatalf("the packet filter is held by %+v", r.Holders)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("the packet filter is not listed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAClaimOutsideTheSetIsShownNotHidden(t *testing.T) {
|
||||||
|
// A manifest registered before the set closed can still hold one. Leaving it out would make
|
||||||
|
// the overview quietly incomplete, which is the one thing it may not be.
|
||||||
|
_, outside := seatsHeld(catalogue.Seats(), []catalogue.Held{
|
||||||
|
{Claim: "the-controller", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "mesh-controller"},
|
||||||
|
})
|
||||||
|
if len(outside) != 1 || outside[0].Claim != "the-controller" {
|
||||||
|
t.Fatalf("a claim outside the set was not shown: %+v", outside)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// where a build's repository is (novox/hq ADR 0111).
|
||||||
|
//
|
||||||
|
// A repository is on the mesh's own forge, or it is anywhere else. The first is recorded as its path
|
||||||
|
// on the forge holding the git seat, and cloned from wherever that forge runs at the moment of
|
||||||
|
// building; the second is a URL, recorded and cloned exactly as given. The build machine is not told
|
||||||
|
// the difference — it is handed a URL either way — because only the control plane knows where the
|
||||||
|
// seat's holder runs.
|
||||||
|
|
||||||
|
// gitSeat is the seat a self-hosted repository lives on.
|
||||||
|
const gitSeat = "git"
|
||||||
|
|
||||||
|
// buildSource is where a build's repository is: a URL, or a path on a seat's holder.
|
||||||
|
type buildSource struct {
|
||||||
|
Repository string
|
||||||
|
Seat string
|
||||||
|
}
|
||||||
|
|
||||||
|
// String is the source as a person reads it, which for one on a seat is not the URL: the URL is a
|
||||||
|
// fact about where the forge happens to run today.
|
||||||
|
func (s buildSource) String() string {
|
||||||
|
if s.Seat == "" {
|
||||||
|
return s.Repository
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s on the %s seat", s.Repository, s.Seat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// onASeat refuses an address given as a path on the forge.
|
||||||
|
//
|
||||||
|
// **A URL here would be recorded as a path**, and then composed onto the forge's address as one —
|
||||||
|
// cloning `http://forge:3000/https://github.com/…`. Refused by what an address plainly looks like,
|
||||||
|
// not repaired: `--self` promises a path, and something that is not one is a mistake to name.
|
||||||
|
func onASeat(repository string) error {
|
||||||
|
if strings.Contains(repository, ":") || strings.HasPrefix(repository, "/") ||
|
||||||
|
strings.Trim(repository, "/") == "" {
|
||||||
|
return fmt.Errorf("--self takes the repository's path on the forge, such as novox/mesh-catalog, "+
|
||||||
|
"and %q is not one — without --self it is built from exactly what is given", repository)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneFrom is the URL a build machine clones for a source.
|
||||||
|
//
|
||||||
|
// A URL is itself. A path on a seat is composed from the seat's holder as the mesh sees it now —
|
||||||
|
// the same view planning takes of every machine, so the forge a build clones from is the forge the
|
||||||
|
// mesh says holds the seat.
|
||||||
|
func cloneFrom(ctx context.Context, source buildSource) (string, error) {
|
||||||
|
if source.Seat == "" {
|
||||||
|
return source.Repository, nil
|
||||||
|
}
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
world, err := theRestOfTheMesh(ctx, open.inventory, shelf, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return clonedFromSeat(world, source.Seat, source.Repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clonedFromSeat composes the clone URL for a repository on a seat's holder.
|
||||||
|
//
|
||||||
|
// **Refused, never defaulted, at every step that has no answer.** Nobody holding the seat is a mesh
|
||||||
|
// without a forge of its own: it builds from external repositories and must say so rather than fail
|
||||||
|
// to clone. A holder off the private network cannot be reached by any build machine. A holder that
|
||||||
|
// serves no scheme or port has nothing to compose from — a default port here would be the forge's
|
||||||
|
// address guessed, which is the thing this exists to stop.
|
||||||
|
func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) {
|
||||||
|
seat, known := catalogue.SeatNamed(seatName)
|
||||||
|
if !known || seat.Delivers == "" {
|
||||||
|
return "", fmt.Errorf("%q is not a seat a repository can live on", seatName)
|
||||||
|
}
|
||||||
|
var holder *catalogue.Held
|
||||||
|
for i, h := range world.Held {
|
||||||
|
if h.Claim == seat.Name && h.Scope == seat.Scope {
|
||||||
|
holder = &world.Held[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if holder == nil {
|
||||||
|
return "", fmt.Errorf("nobody holds the %s seat, so %s cannot be cloned from this mesh's "+
|
||||||
|
"forge — assign a module that claims it, or build from the repository's URL without --self",
|
||||||
|
seat.Name, repository)
|
||||||
|
}
|
||||||
|
var provider *catalogue.Provider
|
||||||
|
for i, p := range world.Offered[seat.Delivers] {
|
||||||
|
if p.Node == holder.Node && p.Module == holder.Module {
|
||||||
|
provider = &world.Offered[seat.Delivers][i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
return "", fmt.Errorf("%s on %s holds the %s seat and offers no %q to clone from",
|
||||||
|
holder.Module, holder.Node, seat.Name, seat.Delivers)
|
||||||
|
}
|
||||||
|
if provider.At == "" {
|
||||||
|
return "", fmt.Errorf("%s on %s holds the %s seat and is not on the private network, so no "+
|
||||||
|
"build machine can reach it", holder.Module, holder.Node, seat.Name)
|
||||||
|
}
|
||||||
|
scheme, _ := provider.Serves["scheme"].(string)
|
||||||
|
port := servedPort(provider.Serves["port"])
|
||||||
|
if scheme == "" || port == "" {
|
||||||
|
return "", fmt.Errorf("%s on %s holds the %s seat and does not serve a scheme and a port for %q",
|
||||||
|
holder.Module, holder.Node, seat.Name, seat.Delivers)
|
||||||
|
}
|
||||||
|
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git")
|
||||||
|
return fmt.Sprintf("%s://%s:%s/%s.git", scheme, provider.At, port, path), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// servedPort is a served port as text, however the manifest and the node's settings carried it.
|
||||||
|
func servedPort(v any) string {
|
||||||
|
switch p := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return strconv.Itoa(int(p))
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(p)
|
||||||
|
case string:
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0111: a build source is on the git seat, or it is external.
|
||||||
|
|
||||||
|
func forgeHolding(port any) catalogue.World {
|
||||||
|
return catalogue.World{
|
||||||
|
Held: []catalogue.Held{{Claim: "git", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "gitea"}},
|
||||||
|
Offered: map[string][]catalogue.Provider{"git": {
|
||||||
|
// A second forge that does not hold the seat, so taking the first one found would be wrong.
|
||||||
|
{Node: "archive", At: "archive.internal", Module: "gitea-mirror",
|
||||||
|
Serves: map[string]any{"scheme": "http", "port": float64(3000)}},
|
||||||
|
{Node: "anchor", At: "anchor.internal", Module: "gitea",
|
||||||
|
Serves: map[string]any{"scheme": "http", "port": port}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARepositoryOnTheSeatIsClonedFromItsHolder(t *testing.T) {
|
||||||
|
got, err := clonedFromSeat(forgeHolding(float64(3000)), "git", "novox/mesh-catalog")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "http://anchor.internal:3000/novox/mesh-catalog.git" {
|
||||||
|
t.Fatalf("cloned from %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMovedForgeIsFollowedWithoutRewritingAnything(t *testing.T) {
|
||||||
|
// The whole point: the node gave the forge another port, and the same recorded path clones
|
||||||
|
// from the new one. Nothing recorded contained the old one to be wrong.
|
||||||
|
got, err := clonedFromSeat(forgeHolding(float64(3100)), "git", "novox/mesh-catalog")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, ":3100/") {
|
||||||
|
t.Fatalf("the moved port was not followed: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithNobodyHoldingTheSeatASelfHostedBuildIsRefusedAndSaysWhy(t *testing.T) {
|
||||||
|
_, err := clonedFromSeat(catalogue.World{}, "git", "novox/mesh-catalog")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a repository was cloned from a forge the mesh does not have")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"nobody holds the git seat", "without --self"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("the refusal does not say %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnExternalRepositoryIsClonedExactlyAsGiven(t *testing.T) {
|
||||||
|
// Unaffected by the seat, held or not: GitHub and GitLab are the ordinary cases.
|
||||||
|
given := "https://github.com/someone/something.git"
|
||||||
|
got, err := cloneFrom(t.Context(), buildSource{Repository: given})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != given {
|
||||||
|
t.Fatalf("an external repository became %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHolderOffThePrivateNetworkIsRefused(t *testing.T) {
|
||||||
|
world := forgeHolding(float64(3000))
|
||||||
|
world.Offered["git"][1].At = ""
|
||||||
|
if _, err := clonedFromSeat(world, "git", "novox/mesh-catalog"); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "private network") {
|
||||||
|
t.Fatalf("a forge nothing can reach was cloned from: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHolderServingNoPortIsRefusedRatherThanGuessed(t *testing.T) {
|
||||||
|
// A default port would be the forge's address guessed, which is what this exists to stop.
|
||||||
|
if _, err := clonedFromSeat(forgeHolding(nil), "git", "novox/mesh-catalog"); err == nil {
|
||||||
|
t.Fatal("a port was guessed for a forge that serves none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAddressGivenAsAPathOnTheForgeIsRefused(t *testing.T) {
|
||||||
|
for _, bad := range []string{
|
||||||
|
"https://github.com/someone/something.git",
|
||||||
|
"git@anchor:novox/mesh-catalog.git",
|
||||||
|
"/srv/git/mesh-catalog",
|
||||||
|
"",
|
||||||
|
} {
|
||||||
|
if err := onASeat(bad); err == nil {
|
||||||
|
t.Errorf("--self accepted %q as a path on the forge", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := onASeat("novox/mesh-catalog"); err != nil {
|
||||||
|
t.Errorf("a path on the forge was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASourceOnTheSeatReadsAsAPathNotAnAddress(t *testing.T) {
|
||||||
|
s := buildSource{Repository: "novox/mesh-catalog", Seat: "git"}
|
||||||
|
if got := s.String(); got != "novox/mesh-catalog on the git seat" {
|
||||||
|
t.Fatalf("read as %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The challenge path falls through for real. autocert's own HTTPHandler answers 404 itself for a
|
||||||
|
// token it does not hold and never consults its fallback on the challenge path — the
|
||||||
|
// predecessor's fault, the edge owning /.well-known/acme-challenge outright, rediscovered live
|
||||||
|
// when Mailu's renewal died behind this proxy on cutover day (2026-09-26). These tests pin the
|
||||||
|
// three behaviours tokenOrRoute exists for.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/acme/autocert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func routedTo(t *testing.T, marker string) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if _, err := w.Write([]byte(marker)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATokenNoAuthorityHoldsIsRoutedNot404d(t *testing.T) {
|
||||||
|
m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())}
|
||||||
|
h := tokenOrRoute(routedTo(t, "the workload answered"), m)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "http://mail.example/.well-known/acme-challenge/somebody-elses-token", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != "the workload answered" {
|
||||||
|
t.Fatalf("a token no authority holds must reach plain routing; got %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATokenAManagerHoldsIsAnsweredByIt(t *testing.T) {
|
||||||
|
// autocert reads a token it does not have in memory from its cache, under "<token>+http-01" —
|
||||||
|
// which is also how a token would survive the manager restarting mid-issuance.
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "held-token+http-01"), []byte("the-key-authorization"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(dir)}
|
||||||
|
h := tokenOrRoute(routedTo(t, "must not be reached"), m)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "http://mail.example/.well-known/acme-challenge/held-token", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != "the-key-authorization" {
|
||||||
|
t.Fatalf("the manager holding a token answers it; got %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASecondAuthorityIsProbedBeforeRouting(t *testing.T) {
|
||||||
|
first := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())}
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "internal-token+http-01"), []byte("internal-key"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(dir)}
|
||||||
|
h := tokenOrRoute(routedTo(t, "must not be reached"), first, second)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "http://git.internal/.well-known/acme-challenge/internal-token", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != "internal-key" {
|
||||||
|
t.Fatalf("the second authority's token is found by probing past the first; got %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAuthorityWhosePolicyRefusesTheNameIsProbedPast(t *testing.T) {
|
||||||
|
// autocert checks the host policy before the token and answers 403 — the internal authority
|
||||||
|
// does this for every public name. A policy refusal is as much "not mine" as a missing token:
|
||||||
|
// the request must still reach plain routing, where the workload's own ACME client answers.
|
||||||
|
refusing := &autocert.Manager{
|
||||||
|
Prompt: autocert.AcceptTOS,
|
||||||
|
Cache: autocert.DirCache(t.TempDir()),
|
||||||
|
HostPolicy: func(ctx context.Context, host string) error {
|
||||||
|
return fmt.Errorf("no internal-only route for %q in this mesh", host)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := tokenOrRoute(routedTo(t, "the workload answered"), refusing)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "http://mail.example/.well-known/acme-challenge/mailus-token", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != "the workload answered" {
|
||||||
|
t.Fatalf("a policy refusal must fall through to routing; got %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnOrdinaryPathNeverTouchesTheChallengeMachinery(t *testing.T) {
|
||||||
|
m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())}
|
||||||
|
h := tokenOrRoute(routedTo(t, "routed"), m)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "http://site.example/index.html", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != "routed" {
|
||||||
|
t.Fatalf("an ordinary path goes straight to routing; got %d %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
+582
-65
@@ -10,10 +10,31 @@
|
|||||||
// program. What lives here is that contract, written as something that runs so it can be read
|
// program. What lives here is that contract, written as something that runs so it can be read
|
||||||
// rather than described.
|
// rather than described.
|
||||||
//
|
//
|
||||||
|
// **A route also carries what a request arriving at it may do** (novox/hq ADR 0108). The grant used
|
||||||
|
// to say only where to send traffic, so this proxy applied nothing; the four things the ingress it
|
||||||
|
// replaces actually relies on are now part of the contribution. The set is closed at four, because
|
||||||
|
// an open middleware surface recreates the thing being replaced and is far harder to narrow later
|
||||||
|
// than a closed one is to widen.
|
||||||
|
//
|
||||||
// What it is given, written by the host from an ordinary declaration:
|
// What it is given, written by the host from an ordinary declaration:
|
||||||
//
|
//
|
||||||
// $ROUTES every consumer, the name it asked for, and where the mesh says that machine is
|
// $ROUTES every consumer, the name it asked for, and where the mesh says that machine is
|
||||||
//
|
//
|
||||||
|
// Each contribution's values carry the name and port as before, and optionally:
|
||||||
|
//
|
||||||
|
// path the path prefix this rule is scoped to; absent means every path
|
||||||
|
// priority which rule wins where two match; higher first, and the order is total
|
||||||
|
// deny refuse the request outright — the shape an incident mitigation needs
|
||||||
|
// redirect answer with a permanent redirect to this name, keeping the path and query
|
||||||
|
// auth the *path of a secret* holding `user:hash` lines, never the credential itself
|
||||||
|
//
|
||||||
|
// A host may appear more than once, which is what path scoping means: one rule refusing a path
|
||||||
|
// while another serves everything else on the same name.
|
||||||
|
//
|
||||||
|
// **`auth` names a secret and never holds one.** A declaration carrying a credential is refused
|
||||||
|
// outright rather than served unprotected, and a secret that cannot be read makes the route refuse
|
||||||
|
// rather than open — a gate that cannot check is not a gate that opens.
|
||||||
|
//
|
||||||
// It re-reads on change rather than being restarted, for the same reason the provisioner does:
|
// It re-reads on change rather than being restarted, for the same reason the provisioner does:
|
||||||
// a route arriving or leaving is an ordinary event and must not drop the connections of every
|
// a route arriving or leaving is an ordinary event and must not drop the connections of every
|
||||||
// other workload.
|
// other workload.
|
||||||
@@ -23,6 +44,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -42,6 +64,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/crypto/acme"
|
"golang.org/x/crypto/acme"
|
||||||
"golang.org/x/crypto/acme/autocert"
|
"golang.org/x/crypto/acme/autocert"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Where public certificates come from when nothing says otherwise.
|
// Where public certificates come from when nothing says otherwise.
|
||||||
@@ -74,10 +97,23 @@ func issuer() string {
|
|||||||
// to what it may serve.
|
// to what it may serve.
|
||||||
func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy {
|
func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy {
|
||||||
return func(_ context.Context, host string) error {
|
return func(_ context.Context, host string) error {
|
||||||
if _, known := held.find(host); known {
|
if held.eligibleForACME(host) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no route for %q in this mesh, so no certificate is asked for", host)
|
return fmt.Errorf("no public route for %q in this mesh, so no certificate is asked for", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyInternalNamesTheMeshSaid is onlyWhatTheMeshSaid's mirror for the internal authority — the
|
||||||
|
// same quota-spending concern applies even to an authority with no rate limit of its own, because
|
||||||
|
// an order for a name this proxy does not actually route is a bug worth refusing rather than
|
||||||
|
// serving.
|
||||||
|
func onlyInternalNamesTheMeshSaid(held *table) autocert.HostPolicy {
|
||||||
|
return func(_ context.Context, host string) error {
|
||||||
|
if held.eligibleForInternalACME(host) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no internal-only route for %q in this mesh, so no certificate is asked for", host)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,48 +131,184 @@ type contribution struct {
|
|||||||
Values map[string]any `json:"values"`
|
Values map[string]any `json:"values"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// policy is what a rule does with a request that matched it.
|
||||||
|
//
|
||||||
|
// **Decided by the mesh, not here** (novox/hq ADR 0108). A route grant used to hand back a name and
|
||||||
|
// say nothing about what the name admitted, so this proxy admitted everything. The set is closed at
|
||||||
|
// four — authentication, refusal, path scoping, redirect — because an open middleware surface
|
||||||
|
// recreates the thing being replaced and is far harder to narrow later than a closed one is to widen.
|
||||||
|
type policy struct {
|
||||||
|
// deny refuses the request outright, whatever it is.
|
||||||
|
deny bool
|
||||||
|
// redirectTo answers with a permanent redirect instead of proxying. The request's own path and
|
||||||
|
// query are carried across, which is what canonicalising one public name onto another means.
|
||||||
|
redirectTo string
|
||||||
|
// users is what a request must present, read at load time from the secret the declaration
|
||||||
|
// *named*. A declaration never carries the credential itself.
|
||||||
|
users map[string]string
|
||||||
|
// sealed is set when authentication was declared and the secret could not be read. The rule then
|
||||||
|
// refuses everything and says why.
|
||||||
|
//
|
||||||
|
// **Fail closed.** The alternative — serve the route unauthenticated because the gate is
|
||||||
|
// missing — turns an unreadable file into a silently public admin surface, which is the exact
|
||||||
|
// outcome ADR 0108 exists to prevent. A gate that cannot check is not a gate that opens.
|
||||||
|
sealed string
|
||||||
|
}
|
||||||
|
|
||||||
|
// rule is one way a host may be routed. A host may have several, which is what path scoping means.
|
||||||
|
type rule struct {
|
||||||
|
path string // "" matches every path
|
||||||
|
priority int
|
||||||
|
policy policy
|
||||||
|
to *httputil.ReverseProxy
|
||||||
|
target string
|
||||||
|
// insecure skips certificate verification when target is reached over https. For a backend
|
||||||
|
// that terminates TLS with its own certificate this proxy has no reason to trust — Mailu's
|
||||||
|
// webmail front is the first of these — never for anything reached over plain http, where
|
||||||
|
// there is nothing to verify in the first place.
|
||||||
|
insecure bool
|
||||||
|
}
|
||||||
|
|
||||||
// table is what the proxy is currently serving, replaced whole whenever the file changes.
|
// table is what the proxy is currently serving, replaced whole whenever the file changes.
|
||||||
//
|
//
|
||||||
// Replaced rather than merged: the file is the whole truth about who has a route, so merging
|
// Replaced rather than merged: the file is the whole truth about who has a route, so merging
|
||||||
// would keep serving a name whose module was unassigned — which is the stale-route fault
|
// would keep serving a name whose module was unassigned — which is the stale-route fault
|
||||||
// 08-connectivity lists as open, reintroduced one level down.
|
// 08-connectivity lists as open, reintroduced one level down.
|
||||||
|
//
|
||||||
|
// Keyed by host to an *ordered* list rather than to one target, because two of the four policies
|
||||||
|
// need a single host routed more than one way: a refusal on a path the ordinary route also matches,
|
||||||
|
// and a certificate-challenge path on a host that otherwise serves a workload.
|
||||||
type table struct {
|
type table struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
to map[string]*httputil.ReverseProxy
|
to map[string][]rule
|
||||||
targets map[string]string
|
// public is which routed hosts are eligible for a real certificate — every host reached as a
|
||||||
|
// route's own `name`, never one reached only as its `internal-name`. A private alias can never
|
||||||
|
// pass ACME's own validation (it has no public DNS to prove it against), so asking for it is
|
||||||
|
// not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent.
|
||||||
|
public map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *table) set(routes map[string]string) {
|
func (t *table) set(routes map[string][]rule, public map[string]bool) {
|
||||||
made := map[string]*httputil.ReverseProxy{}
|
made := map[string][]rule{}
|
||||||
for name, target := range routes {
|
for host, rules := range routes {
|
||||||
where, err := url.Parse(target)
|
kept := make([]rule, 0, len(rules))
|
||||||
if err != nil {
|
for _, r := range rules {
|
||||||
log.Printf("route %s points at %q, which is not a URL: %v", name, target, err)
|
// A rule that only refuses or only redirects has nowhere to send anything, and needs
|
||||||
|
// nowhere: it answers by itself.
|
||||||
|
if r.policy.deny || r.policy.redirectTo != "" {
|
||||||
|
kept = append(kept, r)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made[name] = httputil.NewSingleHostReverseProxy(where)
|
where, err := url.Parse(r.target)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("route %s points at %q, which is not a URL: %v", host, r.target, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.to = httputil.NewSingleHostReverseProxy(where)
|
||||||
|
if r.insecure {
|
||||||
|
r.to.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
||||||
|
}
|
||||||
|
kept = append(kept, r)
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inOrder(kept)
|
||||||
|
made[host] = kept
|
||||||
}
|
}
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
t.to, t.targets = made, routes
|
t.to = made
|
||||||
|
t.public = public
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *table) find(host string) (*httputil.ReverseProxy, bool) {
|
// inOrder puts the rules for one host into the order they are matched in, and does so totally.
|
||||||
// The port is not part of the name. A request to app.example:8080 is for app.example.
|
//
|
||||||
|
// **Equal priorities must resolve identically every time** (ADR 0108). Sorting only by priority
|
||||||
|
// leaves rules that share one in whatever order the map produced, so the same declaration would
|
||||||
|
// serve differently between restarts — a proxy that is not reproducible. Longest path first within a
|
||||||
|
// priority is also the intuitive reading: the more specific rule wins. The last two keys exist only
|
||||||
|
// to make the order total.
|
||||||
|
func inOrder(rules []rule) {
|
||||||
|
sort.SliceStable(rules, func(i, j int) bool {
|
||||||
|
a, b := rules[i], rules[j]
|
||||||
|
if a.priority != b.priority {
|
||||||
|
return a.priority > b.priority
|
||||||
|
}
|
||||||
|
if len(a.path) != len(b.path) {
|
||||||
|
return len(a.path) > len(b.path)
|
||||||
|
}
|
||||||
|
if a.path != b.path {
|
||||||
|
return a.path < b.path
|
||||||
|
}
|
||||||
|
return a.target < b.target
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// find is the rule that answers this request, or nothing if the host is not routed here at all.
|
||||||
|
func (t *table) find(host, path string) (rule, bool) {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
for _, r := range t.to[bareHost(host)] {
|
||||||
|
if r.path == "" || strings.HasPrefix(path, r.path) {
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rule{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// routed says whether this proxy serves the name at all, whatever the path.
|
||||||
|
//
|
||||||
|
// Separate from find because certificate issuance is a question about the *name*: a host whose only
|
||||||
|
// rules are path-scoped is still a name this proxy answers to, and still needs a certificate.
|
||||||
|
// eligibleForACME says whether this proxy may ask a certificate authority for this name — every
|
||||||
|
// host reached as a route's own public `name`, never one reached only as its `internal-name`
|
||||||
|
// alias, which no public CA can ever validate.
|
||||||
|
func (t *table) eligibleForACME(host string) bool {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
bare := bareHost(host)
|
||||||
|
return len(t.to[bare]) > 0 && t.public[bare]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *table) routed(host string) bool {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return len(t.to[bareHost(host)]) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// eligibleForInternalACME says whether this proxy may ask its *internal* authority for a
|
||||||
|
// certificate for this name — every host it routes that is not also a route's public `name`.
|
||||||
|
//
|
||||||
|
// **The mesh has two name spaces and two authorities** (novox/hq 03-DESIGN/01-to-be/08-connectivity
|
||||||
|
// §2): a public name is certified by a public CA, an internal one by the mesh's own. This is
|
||||||
|
// composed only from `to` and `public`, which routesFrom already builds correctly — a host never
|
||||||
|
// lands in both a route's own `name` and only its `internal-name`, so nothing new has to be
|
||||||
|
// tracked to tell the two apart.
|
||||||
|
func (t *table) eligibleForInternalACME(host string) bool {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
bare := bareHost(host)
|
||||||
|
return len(t.to[bare]) > 0 && !t.public[bare]
|
||||||
|
}
|
||||||
|
|
||||||
|
// bareHost is the name without the port, lower-cased.
|
||||||
|
//
|
||||||
|
// The port is not part of the name: a request to app.example:8080 is for app.example. Lower-cased
|
||||||
|
// because a Host header is not case-sensitive, and a route that only answers the spelling in the
|
||||||
|
// manifest answers half the requests made to it.
|
||||||
|
func bareHost(host string) string {
|
||||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
host = h
|
host = h
|
||||||
}
|
}
|
||||||
t.mu.RLock()
|
return strings.ToLower(host)
|
||||||
defer t.mu.RUnlock()
|
|
||||||
p, ok := t.to[strings.ToLower(host)]
|
|
||||||
return p, ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *table) names() []string {
|
func (t *table) names() []string {
|
||||||
t.mu.RLock()
|
t.mu.RLock()
|
||||||
defer t.mu.RUnlock()
|
defer t.mu.RUnlock()
|
||||||
out := make([]string, 0, len(t.targets))
|
out := make([]string, 0, len(t.to))
|
||||||
for name := range t.targets {
|
for name := range t.to {
|
||||||
out = append(out, name)
|
out = append(out, name)
|
||||||
}
|
}
|
||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
@@ -162,7 +334,7 @@ func run() error {
|
|||||||
|
|
||||||
held := newTable()
|
held := newTable()
|
||||||
read := func() {
|
read := func() {
|
||||||
routes, err := routesFrom(path)
|
routes, public, err := routesFrom(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Kept serving what it had. A file being rewritten is momentarily unreadable, and
|
// Kept serving what it had. A file being rewritten is momentarily unreadable, and
|
||||||
// dropping every route because one read landed mid-write would turn an ordinary
|
// dropping every route because one read landed mid-write would turn an ordinary
|
||||||
@@ -170,7 +342,7 @@ func run() error {
|
|||||||
log.Printf("cannot read %s, keeping what is already served: %v", path, err)
|
log.Printf("cannot read %s, keeping what is already served: %v", path, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
held.set(routes)
|
held.set(routes, public)
|
||||||
log.Printf("serving %d route(s): %s", len(routes), strings.Join(held.names(), ", "))
|
log.Printf("serving %d route(s): %s", len(routes), strings.Join(held.names(), ", "))
|
||||||
}
|
}
|
||||||
read()
|
read()
|
||||||
@@ -197,16 +369,158 @@ func run() error {
|
|||||||
return fmt.Errorf("TLS_LISTEN is set and ACME_CACHE is not: certificates need somewhere " +
|
return fmt.Errorf("TLS_LISTEN is set and ACME_CACHE is not: certificates need somewhere " +
|
||||||
"to persist, or every restart orders them again")
|
"to persist, or every restart orders them again")
|
||||||
}
|
}
|
||||||
client := &acme.Client{DirectoryURL: issuer()}
|
publicManager, err := newManager(cache, issuer(), strings.TrimSpace(os.Getenv("ACME_CA_BUNDLE")),
|
||||||
// An issuer that is not one of the public ones serves its own API over TLS with a certificate
|
onlyWhatTheMeshSaid(held))
|
||||||
// nothing trusts yet — the lab's, or an internal step-ca. Trusting it is a deliberate act and
|
if err != nil {
|
||||||
// names a file, rather than the client being told to skip verification: *skip* would also
|
return err
|
||||||
// apply on the day this points at a public issuer, and nothing would say so.
|
}
|
||||||
|
log.Printf("issuing public certificates from %s, for whatever the mesh routes here", issuer())
|
||||||
|
|
||||||
|
// The internal authority is optional: unset means this proxy serves internal-only aliases over
|
||||||
|
// plain HTTP exactly as it always has, which is the standalone-binary default and a safe one —
|
||||||
|
// it asks nothing of an authority it was not told about.
|
||||||
|
var internalManager *autocert.Manager
|
||||||
|
if directory := strings.TrimSpace(os.Getenv("INTERNAL_ACME_DIRECTORY")); directory != "" {
|
||||||
|
internalManager, err = newManager(cache, directory, strings.TrimSpace(os.Getenv("INTERNAL_ACME_CA_BUNDLE")),
|
||||||
|
onlyInternalNamesTheMeshSaid(held))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("internal certificate authority: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("issuing internal certificates from %s, for every internal-only alias this routes",
|
||||||
|
directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port 80 answers the HTTP-01 challenge and goes on proxying everything else. The challenge
|
||||||
|
// must be answered *at the name being certified*, which is why issuance happens on the node
|
||||||
|
// that is publicly reachable rather than wherever the workload runs.
|
||||||
|
//
|
||||||
|
// **autocert's own HTTPHandler does not fall through on the challenge path.** For a token it
|
||||||
|
// does not hold it answers 404 itself; its fallback only ever sees non-challenge paths — which
|
||||||
|
// is exactly the predecessor's fault, the edge owning `/.well-known/acme-challenge` outright,
|
||||||
|
// rediscovered live when Mailu's renewal died behind this proxy on cutover day. tokenOrRoute
|
||||||
|
// probes each manager and hands a token neither authority recognises to plain routing, which
|
||||||
|
// is what lets a consumer's own ACME client — Mailu's, certifying its own name for a protocol
|
||||||
|
// this proxy never proxies — answer its own challenge through an ordinary path-scoped route.
|
||||||
|
port80 := tokenOrRoute(handler(held), publicManager)
|
||||||
|
if internalManager != nil {
|
||||||
|
port80 = tokenOrRoute(handler(held), publicManager, internalManager)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := http.ListenAndServe(listen, port80); err != nil {
|
||||||
|
log.Printf("plain HTTP stopped: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
tlsConfig := publicManager.TLSConfig()
|
||||||
|
if internalManager != nil {
|
||||||
|
// Dispatched by which authority may certify this name at all — the same question
|
||||||
|
// eligibleForInternalACME already answers, asked once more at handshake time rather than
|
||||||
|
// only when an order is placed, since a cached certificate is served here on every request
|
||||||
|
// and never goes through HostPolicy again.
|
||||||
|
fromPublic, fromInternal := tlsConfig.GetCertificate, internalManager.TLSConfig().GetCertificate
|
||||||
|
tlsConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
if held.eligibleForInternalACME(hello.ServerName) {
|
||||||
|
return fromInternal(hello)
|
||||||
|
}
|
||||||
|
return fromPublic(hello)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: secure,
|
||||||
|
Handler: handler(held),
|
||||||
|
TLSConfig: tlsConfig,
|
||||||
|
}
|
||||||
|
return server.ListenAndServeTLS("", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenOrRoute serves port 80: each manager answers the challenge tokens it is itself holding,
|
||||||
|
// and a token none of them holds is routed like any other request instead of being 404'd at the
|
||||||
|
// edge.
|
||||||
|
//
|
||||||
|
// autocert gives no way to ask "is this your token?" — its HTTPHandler both answers and refuses —
|
||||||
|
// so each manager is probed against a buffered writer and its refusal (404 on the challenge path)
|
||||||
|
// is discarded in favour of the next candidate. The probe is cheap: the handler answers from
|
||||||
|
// memory, and the path only carries traffic while an issuance is actually running.
|
||||||
|
func tokenOrRoute(routes http.Handler, managers ...*autocert.Manager) http.Handler {
|
||||||
|
const challengePrefix = "/.well-known/acme-challenge/"
|
||||||
|
// Non-challenge paths never reach a manager at all; autocert's tryHTTP01 switch still has to
|
||||||
|
// be armed, which HTTPHandler is the only exported way to do.
|
||||||
|
probes := make([]http.Handler, len(managers))
|
||||||
|
for i, m := range managers {
|
||||||
|
probes[i] = m.HTTPHandler(routes)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, challengePrefix) {
|
||||||
|
routes.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, probe := range probes {
|
||||||
|
buffered := &probedResponse{header: make(http.Header)}
|
||||||
|
probe.ServeHTTP(buffered, r)
|
||||||
|
// Two shapes of "not mine": 404, a token this manager is not holding — and 403, a
|
||||||
|
// name its host policy would never certify at all (autocert checks the policy before
|
||||||
|
// the token, so the internal authority answers 403 for every public name).
|
||||||
|
if buffered.status == http.StatusNotFound || buffered.status == http.StatusForbidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buffered.replayTo(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
routes.ServeHTTP(w, r) // no authority holds it: the workload behind a routed path may
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// probedResponse buffers one handler's answer so a refusal can be discarded unseen.
|
||||||
|
type probedResponse struct {
|
||||||
|
header http.Header
|
||||||
|
status int
|
||||||
|
body bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *probedResponse) Header() http.Header { return p.header }
|
||||||
|
|
||||||
|
func (p *probedResponse) WriteHeader(status int) {
|
||||||
|
if p.status == 0 {
|
||||||
|
p.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *probedResponse) Write(b []byte) (int, error) {
|
||||||
|
if p.status == 0 {
|
||||||
|
p.status = http.StatusOK
|
||||||
|
}
|
||||||
|
return p.body.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *probedResponse) replayTo(w http.ResponseWriter) {
|
||||||
|
for k, vs := range p.header {
|
||||||
|
for _, v := range vs {
|
||||||
|
w.Header().Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := p.status
|
||||||
|
if status == 0 {
|
||||||
|
status = http.StatusOK
|
||||||
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write(p.body.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// newManager is one ACME authority's autocert manager: where to ask, what to trust it with, and
|
||||||
|
// which names it may be asked to certify.
|
||||||
|
//
|
||||||
|
// **Trusting an authority names a file rather than skipping verification.** An issuer that is not
|
||||||
|
// one of the public ones — the lab's, or the mesh's own step-ca — serves its own ACME API over TLS
|
||||||
|
// with a certificate nothing trusts yet. *Skip* would also apply the day this points at a public
|
||||||
|
// issuer, and nothing would say so; naming a bundle is a deliberate, visible act instead.
|
||||||
|
func newManager(cache, directory, bundle string, policy autocert.HostPolicy) (*autocert.Manager, error) {
|
||||||
|
client := &acme.Client{DirectoryURL: directory}
|
||||||
var root []byte
|
var root []byte
|
||||||
if bundle := strings.TrimSpace(os.Getenv("ACME_CA_BUNDLE")); bundle != "" {
|
if bundle != "" {
|
||||||
read, err := os.ReadFile(bundle)
|
read, err := os.ReadFile(bundle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ACME_CA_BUNDLE names %s and it cannot be read: %w", bundle, err)
|
return nil, fmt.Errorf("the CA bundle names %s and it cannot be read: %w", bundle, err)
|
||||||
}
|
}
|
||||||
root = read
|
root = read
|
||||||
// An empty bundle means the issuer's root is already in the system trust store — a public
|
// An empty bundle means the issuer's root is already in the system trust store — a public
|
||||||
@@ -218,7 +532,7 @@ func run() error {
|
|||||||
if strings.TrimSpace(string(root)) != "" {
|
if strings.TrimSpace(string(root)) != "" {
|
||||||
pool := x509.NewCertPool()
|
pool := x509.NewCertPool()
|
||||||
if !pool.AppendCertsFromPEM(root) {
|
if !pool.AppendCertsFromPEM(root) {
|
||||||
return fmt.Errorf("%s holds no certificate this can trust", bundle)
|
return nil, fmt.Errorf("%s holds no certificate this can trust", bundle)
|
||||||
}
|
}
|
||||||
client.HTTPClient = &http.Client{
|
client.HTTPClient = &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -227,31 +541,16 @@ func run() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Where this authority's account and certificates are kept. Per authority, not per proxy — see
|
// Where this authority's account and certificates are kept. Per authority, not per proxy — see
|
||||||
// forThisAuthority, which is what makes a re-initialised CA heal itself.
|
// forThisAuthority, which is what makes a re-initialised CA heal itself, and what lets the
|
||||||
mine := forThisAuthority(cache, issuer(), root)
|
// public and internal authorities share one ACME_CACHE without colliding: they hash to
|
||||||
manager := &autocert.Manager{
|
// different names because their directories differ.
|
||||||
|
mine := forThisAuthority(cache, directory, root)
|
||||||
|
return &autocert.Manager{
|
||||||
Cache: autocert.DirCache(mine),
|
Cache: autocert.DirCache(mine),
|
||||||
Prompt: autocert.AcceptTOS,
|
Prompt: autocert.AcceptTOS,
|
||||||
HostPolicy: onlyWhatTheMeshSaid(held),
|
HostPolicy: policy,
|
||||||
Client: client,
|
Client: client,
|
||||||
}
|
}, nil
|
||||||
log.Printf("issuing from %s into %s, for whatever the mesh routes here", issuer(), mine)
|
|
||||||
|
|
||||||
// Port 80 answers the HTTP-01 challenge and goes on proxying everything else. The challenge
|
|
||||||
// must be answered *at the name being certified*, which is why issuance happens on the node
|
|
||||||
// that is publicly reachable rather than wherever the workload runs.
|
|
||||||
go func() {
|
|
||||||
if err := http.ListenAndServe(listen, manager.HTTPHandler(handler(held))); err != nil {
|
|
||||||
log.Printf("plain HTTP stopped: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
server := &http.Server{
|
|
||||||
Addr: secure,
|
|
||||||
Handler: handler(held),
|
|
||||||
TLSConfig: manager.TLSConfig(),
|
|
||||||
}
|
|
||||||
return server.ListenAndServeTLS("", "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// forThisAuthority is where one ACME authority's account and certificates are kept.
|
// forThisAuthority is where one ACME authority's account and certificates are kept.
|
||||||
@@ -285,61 +584,279 @@ func forThisAuthority(cache, directory string, root []byte) string {
|
|||||||
|
|
||||||
// newTable is an empty routing table.
|
// newTable is an empty routing table.
|
||||||
func newTable() *table {
|
func newTable() *table {
|
||||||
return &table{to: map[string]*httputil.ReverseProxy{}, targets: map[string]string{}}
|
return &table{to: map[string][]rule{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
||||||
func handler(held *table) http.Handler {
|
func handler(held *table) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
proxy, known := held.find(r.Host)
|
matched, known := held.find(r.Host, r.URL.Path)
|
||||||
if !known {
|
if !known {
|
||||||
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
|
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
|
||||||
// are different things, and a proxy that says only "not found" makes an operator go
|
// are different things, and a proxy that says only "not found" makes an operator go
|
||||||
// and read the mesh to tell them apart. What it is serving is the answer to both.
|
// and read the mesh to tell them apart. What it is serving is the answer to both.
|
||||||
|
//
|
||||||
|
// And since a host may now be routed only on some paths, those are a third thing:
|
||||||
|
// saying "no route for this name" while listing that very name as served is a
|
||||||
|
// contradiction an operator would have to disbelieve the proxy to get past.
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
if held.routed(r.Host) {
|
||||||
|
fmt.Fprintf(w, "%s is served here, but no route covers %q.\n",
|
||||||
|
bareHost(r.Host), r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n",
|
fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n",
|
||||||
r.Host, strings.Join(held.names(), ", "))
|
r.Host, strings.Join(held.names(), ", "))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
proxy.ServeHTTP(w, r)
|
|
||||||
|
switch {
|
||||||
|
case matched.policy.sealed != "":
|
||||||
|
// Declared a gate, cannot check it. Refused, and says why — an operator reading this
|
||||||
|
// learns the secret is missing, rather than wondering why a protected name is 503.
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
fmt.Fprintf(w, "this route requires authentication and its credentials cannot be read: %s\n",
|
||||||
|
matched.policy.sealed)
|
||||||
|
return
|
||||||
|
|
||||||
|
case matched.policy.deny:
|
||||||
|
http.Error(w, "this path is not served to you", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
|
||||||
|
case matched.policy.redirectTo != "":
|
||||||
|
http.Redirect(w, r, canonical(matched.policy.redirectTo, r.URL), http.StatusMovedPermanently)
|
||||||
|
return
|
||||||
|
|
||||||
|
case len(matched.policy.users) > 0 && !allowed(matched.policy.users, r):
|
||||||
|
// The realm is the name asked for, so a browser's prompt says which route it is for.
|
||||||
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", bareHost(r.Host)))
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
matched.to.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// routesFrom reads what the mesh wrote and turns it into name → target.
|
// canonical is where a redirect sends this request.
|
||||||
func routesFrom(path string) (map[string]string, error) {
|
//
|
||||||
|
// The declaration names the destination *name*; the request keeps its own path and query. That is
|
||||||
|
// what canonicalising one public name onto another means — a link to a page under the old name has
|
||||||
|
// to arrive at the same page under the new one, or the redirect silently loses every deep link.
|
||||||
|
func canonical(to string, from *url.URL) string {
|
||||||
|
where, err := url.Parse(to)
|
||||||
|
if err != nil {
|
||||||
|
return to
|
||||||
|
}
|
||||||
|
if where.Path == "" || where.Path == "/" {
|
||||||
|
where.Path = from.Path
|
||||||
|
}
|
||||||
|
if where.RawQuery == "" {
|
||||||
|
where.RawQuery = from.RawQuery
|
||||||
|
}
|
||||||
|
return where.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowed says whether the request presented credentials this route accepts.
|
||||||
|
//
|
||||||
|
// **Every path costs one bcrypt comparison**, including an unknown user, which is why the miss
|
||||||
|
// compares against a fixed hash rather than returning early. Returning early would make an unknown
|
||||||
|
// user measurably faster than a known one with a wrong password, and that difference is a way to
|
||||||
|
// enumerate the users of a route from outside it.
|
||||||
|
func allowed(users map[string]string, r *http.Request) bool {
|
||||||
|
// A hash of nothing anybody knows. Its only job is to cost what a real comparison costs.
|
||||||
|
const absent = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
|
||||||
|
user, password, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want, known := users[user]
|
||||||
|
if !known {
|
||||||
|
want = absent
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(want), []byte(password)); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// `known` is checked after the comparison, not instead of it, so the timing is the same either
|
||||||
|
// way. subtle.ConstantTimeByteEq keeps the branch from being the thing that differs.
|
||||||
|
return subtle.ConstantTimeByteEq(boolByte(known), 1) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolByte(b bool) byte {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and
|
||||||
|
// which of those hosts is a public name — the second is `name`, ACME-eligible; a host reached
|
||||||
|
// only through `internal-name` never appears there.
|
||||||
|
func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
var said given
|
var said given
|
||||||
if err := json.Unmarshal(raw, &said); err != nil {
|
if err := json.Unmarshal(raw, &said); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
out := map[string]string{}
|
out := map[string][]rule{}
|
||||||
|
public := map[string]bool{}
|
||||||
for _, c := range said.Given {
|
for _, c := range said.Given {
|
||||||
name, _ := c.Values["name"].(string)
|
name, _ := c.Values["name"].(string)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
host := strings.ToLower(name)
|
||||||
|
public[host] = true
|
||||||
|
|
||||||
|
made := rule{path: asPath(c.Values["path"])}
|
||||||
|
if p, ok := asWhole(c.Values["priority"]); ok {
|
||||||
|
made.priority = p
|
||||||
|
}
|
||||||
|
made.policy.deny, _ = c.Values["deny"].(bool)
|
||||||
|
made.policy.redirectTo, _ = c.Values["redirect"].(string)
|
||||||
|
|
||||||
|
if named, carried := c.Values["auth"].(string); carried && strings.TrimSpace(named) != "" {
|
||||||
|
// **A declaration names a secret; it never holds one** (ADR 0108). Refused rather than
|
||||||
|
// tolerated, and the whole rule is dropped rather than served unprotected — the
|
||||||
|
// rejected option cannot come back by accident, which is the failure this check exists
|
||||||
|
// to make impossible.
|
||||||
|
if looksLikeACredential(named) {
|
||||||
|
log.Printf("%s on %s declared route %q with a credential in the declaration rather "+
|
||||||
|
"than the name of a secret; the whole route is refused (novox/hq ADR 0108)",
|
||||||
|
c.From, c.Node, name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
users, err := usersFrom(named)
|
||||||
|
if err != nil {
|
||||||
|
// Fail closed: the rule is kept so the name stays routed and answers, and it
|
||||||
|
// answers by refusing. Dropping it instead would make the name 404 and read as a
|
||||||
|
// withdrawn route rather than an unreadable secret.
|
||||||
|
made.policy.sealed = err.Error()
|
||||||
|
}
|
||||||
|
made.policy.users = users
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a rule that actually proxies needs somewhere to send the request.
|
||||||
|
if !made.policy.deny && made.policy.redirectTo == "" {
|
||||||
port, ok := asPort(c.Values["port"])
|
port, ok := asPort(c.Values["port"])
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
|
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
|
||||||
c.From, c.Node, name)
|
c.From, c.Node, name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Where the mesh says that machine is. Empty means it is this one — a workload beside the
|
// Where the mesh says that machine is. Empty means it is this one — a workload beside
|
||||||
// proxy is ordinary, and reaching it over loopback is both correct and the only thing
|
// the proxy is ordinary, and reaching it over loopback is both correct and the only
|
||||||
// that works when there is no private network.
|
// thing that works when there is no private network.
|
||||||
at := c.At
|
at := c.At
|
||||||
if at == "" {
|
if at == "" {
|
||||||
at = "127.0.0.1"
|
at = "127.0.0.1"
|
||||||
}
|
}
|
||||||
out[strings.ToLower(name)] = fmt.Sprintf("http://%s:%d", at, port)
|
// http unless the contribution says otherwise. A backend that terminates its own TLS
|
||||||
|
// with a certificate this proxy has no reason to trust — Mailu's webmail front is the
|
||||||
|
// first of these — is the reason `insecure` exists, and it stays the exception: every
|
||||||
|
// other target the mesh hands this proxy is a plain workload on the private network.
|
||||||
|
scheme, _ := c.Values["scheme"].(string)
|
||||||
|
scheme = strings.ToLower(strings.TrimSpace(scheme))
|
||||||
|
if scheme == "" {
|
||||||
|
scheme = "http"
|
||||||
}
|
}
|
||||||
return out, nil
|
if scheme != "http" && scheme != "https" {
|
||||||
|
log.Printf("%s on %s asked for route %q with scheme %q, which is neither http "+
|
||||||
|
"nor https; skipped", c.From, c.Node, name, scheme)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
made.insecure, _ = c.Values["insecure"].(bool)
|
||||||
|
made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
out[host] = append(out[host], made)
|
||||||
|
|
||||||
|
// The internal-network alias, the same rule under a second host — a predecessor proxy
|
||||||
|
// answered both for one route, as a convenience (reaching a service over the VPN without a
|
||||||
|
// public TLS round trip), not as an access boundary; composing it here restores exactly
|
||||||
|
// that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR
|
||||||
|
// 0056's internalDomain half) — the same "nothing to join a label to" case the public name
|
||||||
|
// already has.
|
||||||
|
if internal, _ := c.Values["internal-name"].(string); strings.TrimSpace(internal) != "" {
|
||||||
|
out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, public, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// asWhole is any whole number the mesh wrote, whatever its magnitude.
|
||||||
|
//
|
||||||
|
// **Not asPort.** Priority was read with the port reader first, which caps at 65535 — so a rule
|
||||||
|
// declared at a priority above that silently became priority 0 and stopped shadowing the route it
|
||||||
|
// exists to shadow. The one real rule this has to reproduce is declared at 100000, so the bug was
|
||||||
|
// exactly load-bearing. A priority is an ordering, not a port: it has no range.
|
||||||
|
func asWhole(v any) (int, bool) {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
// JSON makes a float of every number, so a non-integral one was not meant as a priority.
|
||||||
|
if n != float64(int(n)) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return int(n), true
|
||||||
|
case int:
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// asPath is the path prefix a rule is scoped to, or "" for every path.
|
||||||
|
func asPath(v any) string {
|
||||||
|
p, _ := v.(string)
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(p, "/") {
|
||||||
|
p = "/" + p
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeACredential is the check that keeps a secret out of a declaration.
|
||||||
|
//
|
||||||
|
// It errs towards refusing: a value holding a `:` (the htpasswd separator) or opening with a bcrypt
|
||||||
|
// identifier is a credential, not a path, and no filesystem path the mesh writes needs either. A
|
||||||
|
// false refusal is a loud log and a route that does not serve; a false accept is a credential
|
||||||
|
// committed to a declaration, which is the thing being prevented.
|
||||||
|
func looksLikeACredential(v string) bool {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
return strings.Contains(v, ":") || strings.HasPrefix(v, "$2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// usersFrom reads the credentials the mesh mounted, in the one format every htpasswd already is.
|
||||||
|
func usersFrom(path string) (map[string]string, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read the secret named for this route: %w", err)
|
||||||
|
}
|
||||||
|
users := map[string]string{}
|
||||||
|
for _, line := range strings.Split(string(raw), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user, hash, ok := strings.Cut(line, ":")
|
||||||
|
if !ok || user == "" || hash == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
users[user] = hash
|
||||||
|
}
|
||||||
|
if len(users) == 0 {
|
||||||
|
return nil, fmt.Errorf("the secret named for this route holds no usable credentials")
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// asPort accepts what JSON makes of a number, which is a float even when it was written 8080.
|
// asPort accepts what JSON makes of a number, which is a float even when it was written 8080.
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a route carries about the requests arriving at it — novox/hq ADR 0108.
|
||||||
|
//
|
||||||
|
// Each test here is one of the four capabilities that record closed the set at, plus the negative
|
||||||
|
// case it promised would be refused. The negative case is the one that rots quietly: nothing fails
|
||||||
|
// if it stops working, so nothing tells you it has.
|
||||||
|
|
||||||
|
// served starts a workload and gives back the host and port the mesh would have recorded for it.
|
||||||
|
func served(t *testing.T, body string) (string, int) {
|
||||||
|
t.Helper()
|
||||||
|
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(workload.Close)
|
||||||
|
host, port, _ := strings.Cut(strings.TrimPrefix(workload.URL, "http://"), ":")
|
||||||
|
n, err := strconv.Atoi(port)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return host, n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask makes one request through the proxy for a given name and path, without following redirects.
|
||||||
|
func ask(t *testing.T, proxy, name, path string, auth [2]string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodGet, proxy+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Host = name
|
||||||
|
if auth[0] != "" {
|
||||||
|
req.SetBasicAuth(auth[0], auth[1])
|
||||||
|
}
|
||||||
|
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}}
|
||||||
|
answer, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = answer.Body.Close() })
|
||||||
|
return answer
|
||||||
|
}
|
||||||
|
|
||||||
|
func proxyFor(t *testing.T, routesJSON string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "routes.json")
|
||||||
|
if err := os.WriteFile(path, []byte(routesJSON), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
routes, public, err := routesFrom(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held := newTable()
|
||||||
|
held.set(routes, public)
|
||||||
|
server := httptest.NewServer(handler(held))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
return server.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal on a path shadows the ordinary route for that path and leaves every other path alone.
|
||||||
|
//
|
||||||
|
// **This is why path scoping is a prerequisite and not a sibling capability.** The rule being
|
||||||
|
// reproduced matches a path on a host that is already routed to a workload, so a table mapping a
|
||||||
|
// host to one target cannot express it at all — no amount of authentication or source filtering
|
||||||
|
// would have helped.
|
||||||
|
func TestARefusedPathShadowsTheRouteAndLeavesTheRestServed(t *testing.T) {
|
||||||
|
at, port := served(t, "the workload")
|
||||||
|
proxy := proxyFor(t, `{"given":[
|
||||||
|
{"from":"forge","node":"anchor","at":"`+at+`","values":{"name":"forge.example","port":`+strconv.Itoa(port)+`}},
|
||||||
|
{"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","priority":100,"deny":true}}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
if got := ask(t, proxy, "forge.example", "/api/internal/hook", [2]string{}).StatusCode; got != http.StatusForbidden {
|
||||||
|
t.Fatalf("the refused path answered %d, so the block that was put in front of it during an "+
|
||||||
|
"incident is not in front of it any more", got)
|
||||||
|
}
|
||||||
|
if got := ask(t, proxy, "forge.example", "/", [2]string{}).StatusCode; got != http.StatusOK {
|
||||||
|
t.Fatalf("refusing one path took the whole route with it: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A redirect answers with the redirect, and the request keeps its own path and query.
|
||||||
|
//
|
||||||
|
// Losing the path would turn canonicalising one name onto another into "every deep link now lands
|
||||||
|
// on the front page", which is the kind of breakage that produces no error anywhere.
|
||||||
|
func TestARedirectKeepsThePathAndQuery(t *testing.T) {
|
||||||
|
proxy := proxyFor(t, `{"given":[
|
||||||
|
{"from":"site","node":"anchor","values":{"name":"www.example","redirect":"https://example/"}}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
answer := ask(t, proxy, "www.example", "/deep/page?ref=1", [2]string{})
|
||||||
|
if answer.StatusCode != http.StatusMovedPermanently {
|
||||||
|
t.Fatalf("a declared redirect answered %d", answer.StatusCode)
|
||||||
|
}
|
||||||
|
where := answer.Header.Get("Location")
|
||||||
|
if !strings.Contains(where, "/deep/page") || !strings.Contains(where, "ref=1") {
|
||||||
|
t.Fatalf("the redirect dropped the path or the query: %q", where)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication refuses a request with no credentials, admits one with the right ones, and refuses
|
||||||
|
// the wrong ones — with the credentials read from the secret the declaration *named*.
|
||||||
|
func TestAuthenticationAdmitsOnlyWhatTheSecretSays(t *testing.T) {
|
||||||
|
at, port := served(t, "the console")
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("correct horse"), bcrypt.MinCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
secret := filepath.Join(t.TempDir(), "console-auth")
|
||||||
|
if err := os.WriteFile(secret, []byte("# a comment\nadmin:"+string(hash)+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := proxyFor(t, `{"given":[
|
||||||
|
{"from":"console","node":"anchor","at":"`+at+`","values":{"name":"console.example","port":`+strconv.Itoa(port)+`,"auth":"`+secret+`"}}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
if got := ask(t, proxy, "console.example", "/", [2]string{}).StatusCode; got != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("an admin surface with no login of its own answered %d without credentials", got)
|
||||||
|
}
|
||||||
|
if got := ask(t, proxy, "console.example", "/", [2]string{"admin", "wrong"}).StatusCode; got != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("the wrong password answered %d", got)
|
||||||
|
}
|
||||||
|
if got := ask(t, proxy, "console.example", "/", [2]string{"admin", "correct horse"}).StatusCode; got != http.StatusOK {
|
||||||
|
t.Fatalf("the right password answered %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The negative case ADR 0108 promised would be refused: a credential in the declaration.
|
||||||
|
//
|
||||||
|
// **Refused whole, not tolerated and not served unprotected.** A hash carried in a declaration was
|
||||||
|
// the rejected option; nothing in the running system should quietly accept it later, because the
|
||||||
|
// precedent is far easier to set than to withdraw. If this test is deleted the option returns and
|
||||||
|
// nothing else notices.
|
||||||
|
func TestACredentialInTheDeclarationIsRefusedRatherThanServed(t *testing.T) {
|
||||||
|
inline := []string{
|
||||||
|
`{"given":[{"from":"c","node":"n","at":"127.0.0.1","values":{"name":"c.example","port":8080,"auth":"admin:$2a$10$abcdefghijklmnopqrstuv"}}]}`,
|
||||||
|
`{"given":[{"from":"c","node":"n","at":"127.0.0.1","values":{"name":"c.example","port":8080,"auth":"$2a$10$abcdefghijklmnopqrstuv"}}]}`,
|
||||||
|
}
|
||||||
|
for _, body := range inline {
|
||||||
|
path := filepath.Join(t.TempDir(), "routes.json")
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
routes, _, err := routesFrom(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(routes) != 0 {
|
||||||
|
t.Fatalf("a declaration carrying a credential was served anyway: %v", routes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication declared, secret unreadable: the route refuses. It does not serve unprotected.
|
||||||
|
//
|
||||||
|
// **Fail closed.** The alternative turns a missing file into a silently public admin surface, which
|
||||||
|
// is the outcome the whole record exists to prevent. It answers rather than 404s, so an operator
|
||||||
|
// sees "cannot read the credentials" instead of concluding the route was withdrawn.
|
||||||
|
func TestAnUnreadableSecretFailsClosed(t *testing.T) {
|
||||||
|
at, port := served(t, "the console")
|
||||||
|
missing := filepath.Join(t.TempDir(), "not-mounted")
|
||||||
|
|
||||||
|
proxy := proxyFor(t, `{"given":[
|
||||||
|
{"from":"console","node":"anchor","at":"`+at+`","values":{"name":"console.example","port":`+strconv.Itoa(port)+`,"auth":"`+missing+`"}}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
answer := ask(t, proxy, "console.example", "/", [2]string{})
|
||||||
|
if answer.StatusCode == http.StatusOK {
|
||||||
|
t.Fatal("a route whose credentials could not be read served the workload unprotected")
|
||||||
|
}
|
||||||
|
if answer.StatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("expected the route to say it cannot check, got %d", answer.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal priorities resolve the same way every time, so the same declaration serves the same way
|
||||||
|
// after a restart.
|
||||||
|
//
|
||||||
|
// Sorting only by priority leaves rules that share one in whatever order the map produced. The
|
||||||
|
// proxy would still work, and would work differently between restarts — which is the hardest kind
|
||||||
|
// of fault to believe when it is reported.
|
||||||
|
func TestRulesThatShareAPriorityAreStillTotallyOrdered(t *testing.T) {
|
||||||
|
first := []rule{
|
||||||
|
{path: "/a", priority: 10, target: "http://x:1"},
|
||||||
|
{path: "/bb", priority: 10, target: "http://y:2"},
|
||||||
|
{path: "", priority: 10, target: "http://z:3"},
|
||||||
|
}
|
||||||
|
second := []rule{
|
||||||
|
{path: "", priority: 10, target: "http://z:3"},
|
||||||
|
{path: "/bb", priority: 10, target: "http://y:2"},
|
||||||
|
{path: "/a", priority: 10, target: "http://x:1"},
|
||||||
|
}
|
||||||
|
inOrder(first)
|
||||||
|
inOrder(second)
|
||||||
|
for i := range first {
|
||||||
|
if first[i].path != second[i].path || first[i].target != second[i].target {
|
||||||
|
t.Fatalf("two orderings of the same rules disagree at %d: %q vs %q",
|
||||||
|
i, first[i].path, second[i].path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And the more specific rule is matched first, which is the intuitive reading.
|
||||||
|
if first[0].path != "/bb" {
|
||||||
|
t.Fatalf("the longest path is not matched first: %q", first[0].path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority decides before path length does, so a rule can be made to win regardless of specificity.
|
||||||
|
func TestPriorityOutranksPathLength(t *testing.T) {
|
||||||
|
rules := []rule{
|
||||||
|
{path: "/very/long/path", priority: 1, target: "http://x:1"},
|
||||||
|
{path: "", priority: 100, target: "http://y:2"},
|
||||||
|
}
|
||||||
|
inOrder(rules)
|
||||||
|
if rules[0].priority != 100 {
|
||||||
|
t.Fatalf("a higher priority did not win: %+v", rules[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A priority above a port number survives, because a priority is an ordering and not a port.
|
||||||
|
//
|
||||||
|
// **Found by review, and it was load-bearing.** Priority was first read with the port reader, which
|
||||||
|
// caps at 65535 — so a rule declared above that silently became priority 0 and stopped shadowing the
|
||||||
|
// route it exists to shadow. The one real rule this has to reproduce is declared at 100000, so the
|
||||||
|
// capability would have shipped looking complete and doing nothing.
|
||||||
|
func TestAPriorityAboveAPortNumberSurvives(t *testing.T) {
|
||||||
|
at, port := served(t, "the workload")
|
||||||
|
proxy := proxyFor(t, `{"given":[
|
||||||
|
{"from":"forge","node":"anchor","at":"`+at+`","values":{"name":"forge.example","port":`+strconv.Itoa(port)+`}},
|
||||||
|
{"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","priority":100000,"deny":true}}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
if got := ask(t, proxy, "forge.example", "/api/internal/hook", [2]string{}).StatusCode; got != http.StatusForbidden {
|
||||||
|
t.Fatalf("a rule declared at priority 100000 answered %d instead of refusing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A host routed only on some paths says so, rather than claiming the name is not served here.
|
||||||
|
//
|
||||||
|
// Saying "no route for this name" while listing that very name as served is a contradiction an
|
||||||
|
// operator has to disbelieve the proxy to get past — and path scoping makes it reachable, because a
|
||||||
|
// host can now have rules that none of this request's paths match.
|
||||||
|
func TestAHostRoutedOnlyOnSomePathsSaysSo(t *testing.T) {
|
||||||
|
proxy := proxyFor(t, `{"given":[
|
||||||
|
{"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","deny":true}}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
answer := ask(t, proxy, "forge.example", "/elsewhere", [2]string{})
|
||||||
|
if answer.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("an uncovered path answered %d", answer.StatusCode)
|
||||||
|
}
|
||||||
|
body := make([]byte, 256)
|
||||||
|
n, _ := answer.Body.Read(body)
|
||||||
|
said := string(body[:n])
|
||||||
|
if !strings.Contains(said, "is served here") || !strings.Contains(said, "/elsewhere") {
|
||||||
|
t.Fatalf("the refusal does not distinguish an uncovered path from an unserved name: %q", said)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,10 +19,37 @@ func write(t *testing.T, body string) string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// plain is the table an ordinary set of routes makes: one host, one target, no policy.
|
||||||
|
func plain(routes map[string]string) map[string][]rule {
|
||||||
|
out := map[string][]rule{}
|
||||||
|
for host, target := range routes {
|
||||||
|
out[host] = []rule{{target: target}}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// allPublic is every host in a routes map, ACME-eligible — the ordinary case for a test with no
|
||||||
|
// internal-name alias of its own to distinguish.
|
||||||
|
func allPublic(routes map[string][]rule) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for host := range routes {
|
||||||
|
out[host] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetOf is where a host's first matching rule sends a request.
|
||||||
|
func targetOf(routes map[string][]rule, host string) string {
|
||||||
|
if rules := routes[host]; len(rules) > 0 {
|
||||||
|
return rules[0].target
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// A route is a grant: the consumer supplies a target, and where that machine is comes from the
|
// A route is a grant: the consumer supplies a target, and where that machine is comes from the
|
||||||
// mesh rather than from a naming convention the proxy has to know.
|
// mesh rather than from a naming convention the proxy has to know.
|
||||||
func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) {
|
func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) {
|
||||||
routes, err := routesFrom(write(t, `{"contributions":1,"requirement":"route","given":[
|
routes, _, err := routesFrom(write(t, `{"contributions":1,"requirement":"route","given":[
|
||||||
{"from":"app","node":"laptop","at":"laptop.internal","values":{"name":"App.Example","port":8080}}
|
{"from":"app","node":"laptop","at":"laptop.internal","values":{"name":"App.Example","port":8080}}
|
||||||
]}`))
|
]}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -30,28 +57,67 @@ func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Lower-cased, because a Host header is not case-sensitive and a route that only answers the
|
// Lower-cased, because a Host header is not case-sensitive and a route that only answers the
|
||||||
// spelling in the manifest answers half the requests made to it.
|
// spelling in the manifest answers half the requests made to it.
|
||||||
if routes["app.example"] != "http://laptop.internal:8080" {
|
if targetOf(routes, "app.example") != "http://laptop.internal:8080" {
|
||||||
t.Fatalf("the route does not point at the consumer: %v", routes)
|
t.Fatalf("the route does not point at the consumer: %v", routes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A route with an internal-name alias is reachable under both hostnames, pointed at the same
|
||||||
|
// target — the same convenience a predecessor proxy gave for reaching a service over the VPN
|
||||||
|
// without a public TLS round trip.
|
||||||
|
func TestARouteWithAnInternalNameIsReachableUnderBoth(t *testing.T) {
|
||||||
|
routes, public, err := routesFrom(write(t, `{"given":[
|
||||||
|
{"from":"app","node":"anchor","at":"anchor.internal",
|
||||||
|
"values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}}
|
||||||
|
]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if targetOf(routes, "app.example") != "http://anchor.internal:8080" {
|
||||||
|
t.Fatalf("the public name does not point at the consumer: %v", routes)
|
||||||
|
}
|
||||||
|
if targetOf(routes, "app.anchor.internal") != "http://anchor.internal:8080" {
|
||||||
|
t.Fatalf("the internal alias does not point at the same consumer: %v", routes)
|
||||||
|
}
|
||||||
|
if !public["app.example"] {
|
||||||
|
t.Errorf("the public name is not eligible for a certificate: %v", public)
|
||||||
|
}
|
||||||
|
if public["app.anchor.internal"] {
|
||||||
|
t.Errorf("the internal alias is eligible for a certificate no public CA could ever issue: %v",
|
||||||
|
public)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A route with no internal-name composed gets no second host — the ordinary case, unchanged.
|
||||||
|
func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) {
|
||||||
|
routes, _, err := routesFrom(write(t, `{"given":[
|
||||||
|
{"from":"app","node":"anchor","values":{"name":"app.example","port":8080}}
|
||||||
|
]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(routes) != 1 {
|
||||||
|
t.Fatalf("a route with no internal-name grew a second host: %v", routes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A workload beside the proxy is ordinary, and reaching it over loopback is both correct and the
|
// A workload beside the proxy is ordinary, and reaching it over loopback is both correct and the
|
||||||
// only thing that works when there is no private network.
|
// only thing that works when there is no private network.
|
||||||
func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) {
|
func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) {
|
||||||
routes, err := routesFrom(write(t, `{"given":[
|
routes, _, err := routesFrom(write(t, `{"given":[
|
||||||
{"from":"app","node":"anchor","values":{"name":"app.example","port":9000}}
|
{"from":"app","node":"anchor","values":{"name":"app.example","port":9000}}
|
||||||
]}`))
|
]}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if routes["app.example"] != "http://127.0.0.1:9000" {
|
if targetOf(routes, "app.example") != "http://127.0.0.1:9000" {
|
||||||
t.Fatalf("a workload on this machine was not reachable: %v", routes)
|
t.Fatalf("a workload on this machine was not reachable: %v", routes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skipped rather than served wrongly. A route with no port would proxy to :0.
|
// Skipped rather than served wrongly. A route with no port would proxy to :0.
|
||||||
func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) {
|
func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) {
|
||||||
routes, err := routesFrom(write(t, `{"given":[
|
routes, _, err := routesFrom(write(t, `{"given":[
|
||||||
{"from":"a","node":"n","at":"n.internal","values":{"name":"no-port.example"}},
|
{"from":"a","node":"n","at":"n.internal","values":{"name":"no-port.example"}},
|
||||||
{"from":"b","node":"n","at":"n.internal","values":{"port":8080}},
|
{"from":"b","node":"n","at":"n.internal","values":{"port":8080}},
|
||||||
{"from":"c","node":"n","at":"n.internal","values":{"name":"fine.example","port":8080}}
|
{"from":"c","node":"n","at":"n.internal","values":{"name":"fine.example","port":8080}}
|
||||||
@@ -59,11 +125,73 @@ func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(routes) != 1 || routes["fine.example"] == "" {
|
if len(routes) != 1 || targetOf(routes, "fine.example") == "" {
|
||||||
t.Fatalf("an unusable contribution was served: %v", routes)
|
t.Fatalf("an unusable contribution was served: %v", routes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A route may name a target reached over https, for a backend that terminates its own TLS — the
|
||||||
|
// shape Mailu's webmail front needs, which this proxy reaches as a plain workload otherwise.
|
||||||
|
func TestARouteMayTargetHttps(t *testing.T) {
|
||||||
|
routes, _, err := routesFrom(write(t, `{"given":[
|
||||||
|
{"from":"mail","node":"anchor","at":"anchor.internal",
|
||||||
|
"values":{"name":"mail.example","port":7443,"scheme":"https","insecure":true}}
|
||||||
|
]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if targetOf(routes, "mail.example") != "https://anchor.internal:7443" {
|
||||||
|
t.Fatalf("an https target was not built as one: %v", routes)
|
||||||
|
}
|
||||||
|
if !routes["mail.example"][0].insecure {
|
||||||
|
t.Fatal("insecure was declared and not carried onto the rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A scheme that is neither http nor https is refused rather than guessed at.
|
||||||
|
func TestARouteWithAnUnknownSchemeIsSkipped(t *testing.T) {
|
||||||
|
routes, _, err := routesFrom(write(t, `{"given":[
|
||||||
|
{"from":"a","node":"n","at":"n.internal","values":{"name":"bad.example","port":80,"scheme":"ftp"}}
|
||||||
|
]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(routes) != 0 {
|
||||||
|
t.Fatalf("a route with an unusable scheme was served: %v", routes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End to end: a backend terminating TLS with a certificate nothing would ordinarily trust is still
|
||||||
|
// reached when the route declared `insecure`, and the response comes back through unmodified.
|
||||||
|
func TestTheProxyReachesAnInsecureHttpsBackend(t *testing.T) {
|
||||||
|
workload := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("the workload, over its own TLS"))
|
||||||
|
}))
|
||||||
|
defer workload.Close()
|
||||||
|
target := strings.TrimPrefix(workload.URL, "https://")
|
||||||
|
|
||||||
|
held := newTable()
|
||||||
|
routes := map[string][]rule{"mail.example": {{target: "https://" + target, insecure: true}}}
|
||||||
|
held.set(routes, allPublic(routes))
|
||||||
|
|
||||||
|
proxy := httptest.NewServer(handler(held))
|
||||||
|
defer proxy.Close()
|
||||||
|
|
||||||
|
asked, err := http.NewRequest(http.MethodGet, proxy.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
asked.Host = "mail.example"
|
||||||
|
answer, err := http.DefaultClient.Do(asked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer answer.Body.Close()
|
||||||
|
if answer.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("an insecure https backend was not reached: %d", answer.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// End to end through the proxy itself: a request for the name reaches the workload, and a name
|
// End to end through the proxy itself: a request for the name reaches the workload, and a name
|
||||||
// nobody asked for is refused in a way that says what IS served.
|
// nobody asked for is refused in a way that says what IS served.
|
||||||
func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) {
|
func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) {
|
||||||
@@ -75,7 +203,7 @@ func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) {
|
|||||||
host, port, _ := strings.Cut(target, ":")
|
host, port, _ := strings.Cut(target, ":")
|
||||||
|
|
||||||
held := newTable()
|
held := newTable()
|
||||||
held.set(map[string]string{"app.example": "http://" + host + ":" + port})
|
held.set(plain(map[string]string{"app.example": "http://" + host + ":" + port}), allPublic(plain(map[string]string{"app.example": "http://" + host + ":" + port})))
|
||||||
|
|
||||||
proxy := httptest.NewServer(handler(held))
|
proxy := httptest.NewServer(handler(held))
|
||||||
defer proxy.Close()
|
defer proxy.Close()
|
||||||
@@ -120,16 +248,17 @@ func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) {
|
|||||||
// nothing fails more visibly than a stale grant, which is exactly why it must not survive.
|
// nothing fails more visibly than a stale grant, which is exactly why it must not survive.
|
||||||
func TestWithdrawingARouteStopsServingIt(t *testing.T) {
|
func TestWithdrawingARouteStopsServingIt(t *testing.T) {
|
||||||
held := newTable()
|
held := newTable()
|
||||||
held.set(map[string]string{
|
initial := plain(map[string]string{
|
||||||
"going.example": "http://a.internal:80",
|
"going.example": "http://a.internal:80",
|
||||||
"staying.example": "http://b.internal:80",
|
"staying.example": "http://b.internal:80",
|
||||||
})
|
})
|
||||||
held.set(map[string]string{"staying.example": "http://b.internal:80"})
|
held.set(initial, allPublic(initial))
|
||||||
|
held.set(plain(map[string]string{"staying.example": "http://b.internal:80"}), allPublic(plain(map[string]string{"staying.example": "http://b.internal:80"})))
|
||||||
|
|
||||||
if _, still := held.find("going.example"); still {
|
if _, still := held.find("going.example", "/"); still {
|
||||||
t.Fatal("a route whose module was unassigned is still served")
|
t.Fatal("a route whose module was unassigned is still served")
|
||||||
}
|
}
|
||||||
if _, kept := held.find("staying.example"); !kept {
|
if _, kept := held.find("staying.example", "/"); !kept {
|
||||||
t.Fatal("withdrawing one route took another with it")
|
t.Fatal("withdrawing one route took another with it")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,8 +266,8 @@ func TestWithdrawingARouteStopsServingIt(t *testing.T) {
|
|||||||
// A Host header carries a port and the name does not.
|
// A Host header carries a port and the name does not.
|
||||||
func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) {
|
func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) {
|
||||||
held := newTable()
|
held := newTable()
|
||||||
held.set(map[string]string{"app.example": "http://a.internal:8080"})
|
held.set(plain(map[string]string{"app.example": "http://a.internal:8080"}), allPublic(plain(map[string]string{"app.example": "http://a.internal:8080"})))
|
||||||
if _, found := held.find("app.example:8080"); !found {
|
if _, found := held.find("app.example:8080", "/"); !found {
|
||||||
t.Fatal("a request to app.example:8080 did not find the route for app.example")
|
t.Fatal("a request to app.example:8080 did not find the route for app.example")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +297,7 @@ func TestTheIssuerIsStagingUnlessNamed(t *testing.T) {
|
|||||||
// rate limit — and the proxy would look healthy throughout.
|
// rate limit — and the proxy would look healthy throughout.
|
||||||
func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) {
|
func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) {
|
||||||
held := newTable()
|
held := newTable()
|
||||||
held.set(map[string]string{"photos.example": "http://127.0.0.1:8080"})
|
held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}), allPublic(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"})))
|
||||||
policy := onlyWhatTheMeshSaid(held)
|
policy := onlyWhatTheMeshSaid(held)
|
||||||
|
|
||||||
if err := policy(context.Background(), "photos.example"); err != nil {
|
if err := policy(context.Background(), "photos.example"); err != nil {
|
||||||
@@ -181,16 +310,64 @@ func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A certificate is asked for on a route's public name, never on its internal-network alias — no
|
||||||
|
// public CA can validate a private name, and asking anyway would only spend the account's rate
|
||||||
|
// limit on an order that can never succeed.
|
||||||
|
func TestNoCertificateIsAskedForOnAnInternalAlias(t *testing.T) {
|
||||||
|
routes, public, err := routesFrom(write(t, `{"given":[
|
||||||
|
{"from":"app","node":"anchor","at":"anchor.internal",
|
||||||
|
"values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}}
|
||||||
|
]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held := newTable()
|
||||||
|
held.set(routes, public)
|
||||||
|
policy := onlyWhatTheMeshSaid(held)
|
||||||
|
|
||||||
|
if err := policy(context.Background(), "app.example"); err != nil {
|
||||||
|
t.Errorf("the route's public name was refused a certificate: %v", err)
|
||||||
|
}
|
||||||
|
if err := policy(context.Background(), "app.anchor.internal"); err == nil {
|
||||||
|
t.Error("a certificate was ordered for the internal alias, which no public CA can validate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A certificate is asked of the *internal* authority only for a name that is routed here and is
|
||||||
|
// not a route's own public name — the internal-network alias, never the route it accompanies.
|
||||||
|
func TestTheInternalAuthorityOnlyCertifiesInternalOnlyAliases(t *testing.T) {
|
||||||
|
routes, public, err := routesFrom(write(t, `{"given":[
|
||||||
|
{"from":"app","node":"anchor","at":"anchor.internal",
|
||||||
|
"values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}}
|
||||||
|
]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held := newTable()
|
||||||
|
held.set(routes, public)
|
||||||
|
policy := onlyInternalNamesTheMeshSaid(held)
|
||||||
|
|
||||||
|
if err := policy(context.Background(), "app.anchor.internal"); err != nil {
|
||||||
|
t.Errorf("the internal alias was refused by its own authority: %v", err)
|
||||||
|
}
|
||||||
|
if err := policy(context.Background(), "app.example"); err == nil {
|
||||||
|
t.Error("the internal authority certified a route's public name, which the public authority already covers")
|
||||||
|
}
|
||||||
|
if err := policy(context.Background(), "unrouted.internal"); err == nil {
|
||||||
|
t.Error("the internal authority certified a name nobody routed here")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A route withdrawn stops being certifiable, without the proxy restarting.
|
// A route withdrawn stops being certifiable, without the proxy restarting.
|
||||||
func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) {
|
func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) {
|
||||||
held := newTable()
|
held := newTable()
|
||||||
held.set(map[string]string{"photos.example": "http://127.0.0.1:8080"})
|
held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}), allPublic(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"})))
|
||||||
policy := onlyWhatTheMeshSaid(held)
|
policy := onlyWhatTheMeshSaid(held)
|
||||||
if err := policy(context.Background(), "photos.example"); err != nil {
|
if err := policy(context.Background(), "photos.example"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
held.set(nil)
|
held.set(nil, nil)
|
||||||
if err := policy(context.Background(), "photos.example"); err == nil {
|
if err := policy(context.Background(), "photos.example"); err == nil {
|
||||||
t.Fatal("a withdrawn route can still order certificates, so the policy read a copy taken " +
|
t.Fatal("a withdrawn route can still order certificates, so the policy read a copy taken " +
|
||||||
"once rather than what is served now")
|
"once rather than what is served now")
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ type Result struct {
|
|||||||
Built []catalogue.Built
|
Built []catalogue.Built
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GitCredential is the forge credential a clone may present when the server asks for one.
|
||||||
|
//
|
||||||
|
// **Offered, never pushed.** It is written as a git credential-store file and named to git with
|
||||||
|
// `-c credential.helper=store`, so git itself decides when it applies: only on an authentication
|
||||||
|
// challenge, and only for the URL it was written for — scheme, host and port included. A public
|
||||||
|
// repository clones exactly as before, and a repository on any other host is never shown it.
|
||||||
|
type GitCredential struct {
|
||||||
|
// URL is the credential-store line — scheme://user:password@host[:port] — naming the one
|
||||||
|
// server this credential belongs to. Empty means the builder holds none and every clone is
|
||||||
|
// anonymous, as it always was.
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
// Build clones a repository at a ref, reads its manifest, produces what it declares, publishes
|
// Build clones a repository at a ref, reads its manifest, produces what it declares, publishes
|
||||||
// each, and returns the manifest the mesh should hold.
|
// each, and returns the manifest the mesh should hold.
|
||||||
//
|
//
|
||||||
@@ -70,7 +83,8 @@ type Result struct {
|
|||||||
// archive failed would otherwise leave half of itself in the store under a digest the mesh never
|
// archive failed would otherwise leave half of itself in the store under a digest the mesh never
|
||||||
// records — reachable, unreferenced, and indistinguishable from something in use.
|
// records — reachable, unreferenced, and indistinguishable from something in use.
|
||||||
func Build(ctx context.Context, run Runner, publish Publisher,
|
func Build(ctx context.Context, run Runner, publish Publisher,
|
||||||
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc, log Log) (Result, error) {
|
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
|
||||||
|
forge GitCredential, log Log) (Result, error) {
|
||||||
|
|
||||||
say := logging(log)
|
say := logging(log)
|
||||||
say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref))
|
say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref))
|
||||||
@@ -80,6 +94,15 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
// The credential is a file git reads, never an argument: a URL carrying a password in argv
|
||||||
|
// would be readable by anything that can list processes for as long as a clone runs.
|
||||||
|
credentials := ""
|
||||||
|
if forge.URL != "" {
|
||||||
|
credentials = filepath.Join(workspace, "git-credentials")
|
||||||
|
if err := os.WriteFile(credentials, []byte(forge.URL+"\n"), 0o600); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
tree := filepath.Join(workspace, "source")
|
tree := filepath.Join(workspace, "source")
|
||||||
if err := os.RemoveAll(tree); err != nil {
|
if err := os.RemoveAll(tree); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
@@ -87,7 +110,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
// A fresh clone every time rather than a fetch into a tree that is already there. A build
|
// A fresh clone every time rather than a fetch into a tree that is already there. A build
|
||||||
// that reuses a working tree can succeed because of something a previous build left behind,
|
// that reuses a working tree can succeed because of something a previous build left behind,
|
||||||
// and that is a build nobody can reproduce.
|
// and that is a build nobody can reproduce.
|
||||||
if _, err := run(ctx, workspace, "git", "clone", "--quiet", repository, tree); err != nil {
|
if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", repository, tree)...); err != nil {
|
||||||
say("clone", "FAILED: %v", err)
|
say("clone", "FAILED: %v", err)
|
||||||
return Result{}, fmt.Errorf("cannot clone %s: %w", repository, err)
|
return Result{}, fmt.Errorf("cannot clone %s: %w", repository, err)
|
||||||
}
|
}
|
||||||
@@ -177,7 +200,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
||||||
for _, a := range artifacts {
|
for _, a := range artifacts {
|
||||||
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
||||||
made, err := one(ctx, run, publish, manifest.Module, within, commit, a, args, held, npmrcPath, say)
|
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("artifact", "%s FAILED: %v", a.Name, err)
|
say("artifact", "%s FAILED: %v", a.Name, err)
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
@@ -210,6 +233,43 @@ func logging(log Log) func(step, format string, args ...any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// contextFrom clones an image artifact's own build context, when it names one apart from this
|
||||||
|
// module's own repository — a fresh tree, the same way the module's own is, keyed by artifact
|
||||||
|
// name so two artifacts of one module naming different contexts do not collide.
|
||||||
|
func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentials string,
|
||||||
|
from catalogue.ArtifactContext, say func(step, format string, args ...any)) (string, error) {
|
||||||
|
say("context", "cloning %s at %s for %s", from.Repository, refOrHead(from.Ref), artifact)
|
||||||
|
dir := filepath.Join(workspace, "context-"+artifact)
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", from.Repository, dir)...); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot clone %s: %w", from.Repository, err)
|
||||||
|
}
|
||||||
|
if from.Ref != "" {
|
||||||
|
if _, err := run(ctx, dir, "git", "checkout", "--quiet", from.Ref); err != nil {
|
||||||
|
return "", fmt.Errorf("%s has no %s: %w", from.Repository, from.Ref, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
say("context", "done")
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneWith is a git invocation that may offer a stored credential.
|
||||||
|
//
|
||||||
|
// The first `-c credential.helper=` clears every helper the environment might carry, so exactly
|
||||||
|
// one place answers an authentication challenge: the file the builder wrote. Without a file, the
|
||||||
|
// invocation is exactly what it always was.
|
||||||
|
func cloneWith(credentials string, rest ...string) []string {
|
||||||
|
if credentials == "" {
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
return append([]string{
|
||||||
|
"-c", "credential.helper=",
|
||||||
|
"-c", "credential.helper=store --file=" + credentials,
|
||||||
|
}, rest...)
|
||||||
|
}
|
||||||
|
|
||||||
func describePath(path string) string {
|
func describePath(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -333,7 +393,7 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func one(ctx context.Context, run Runner, publish Publisher,
|
func one(ctx context.Context, run Runner, publish Publisher,
|
||||||
module, tree, commit string, a catalogue.Artifact, args []string,
|
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
|
||||||
held map[string]string, npmrc string, say func(step, format string, args ...any)) (catalogue.Built, error) {
|
held map[string]string, npmrc string, say func(step, format string, args ...any)) (catalogue.Built, error) {
|
||||||
|
|
||||||
switch a.Kind {
|
switch a.Kind {
|
||||||
@@ -405,7 +465,27 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
"and start FROM ${<NAME>} (novox/hq ADR 0097)",
|
"and start FROM ${<NAME>} (novox/hq ADR 0097)",
|
||||||
module, a.From, strings.Join(bases, ", "))
|
module, a.From, strings.Join(bases, ", "))
|
||||||
}
|
}
|
||||||
invocation := append([]string{"build", "-f", a.From, "-t", local}, args...)
|
// The recipe is always read from this module's own tree, at this module's own commit — only
|
||||||
|
// the context docker build's final argument names can come from somewhere else, when the
|
||||||
|
// artifact says so.
|
||||||
|
recipePath := a.From
|
||||||
|
buildDir := tree
|
||||||
|
if a.Context != nil {
|
||||||
|
cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, say)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err)
|
||||||
|
}
|
||||||
|
// docker build accepts -f outside the context it is given; the recipe stays exactly
|
||||||
|
// where it was read from and validated against, absolute so the working directory
|
||||||
|
// switching to the cloned context does not change which file that is.
|
||||||
|
absRecipe, err := filepath.Abs(filepath.Join(tree, a.From))
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Built{}, fmt.Errorf("%s: %s's recipe: %w", module, a.Name, err)
|
||||||
|
}
|
||||||
|
recipePath = absRecipe
|
||||||
|
buildDir = cloned
|
||||||
|
}
|
||||||
|
invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...)
|
||||||
if a.Target != "" {
|
if a.Target != "" {
|
||||||
invocation = append(invocation, "--target", a.Target)
|
invocation = append(invocation, "--target", a.Target)
|
||||||
}
|
}
|
||||||
@@ -417,8 +497,8 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
invocation = append(invocation, "--network", "host")
|
invocation = append(invocation, "--network", "host")
|
||||||
}
|
}
|
||||||
invocation = append(invocation, ".")
|
invocation = append(invocation, ".")
|
||||||
say("image", "docker build -f %s", a.From)
|
say("image", "docker build -f %s", recipePath)
|
||||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
if _, err := run(ctx, buildDir, "docker", invocation...); err != nil {
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err)
|
return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err)
|
||||||
}
|
}
|
||||||
say("image", "built, publishing")
|
say("image", "built, publishing")
|
||||||
|
|||||||
@@ -19,12 +19,18 @@ import (
|
|||||||
|
|
||||||
type recorded struct {
|
type recorded struct {
|
||||||
ran []string
|
ran []string
|
||||||
|
// dirs is the directory each entry in ran was run from, same index — so a test can ask not
|
||||||
|
// only what ran but where.
|
||||||
|
dirs []string
|
||||||
images map[string]string
|
images map[string]string
|
||||||
archives map[string]string
|
archives map[string]string
|
||||||
failPush bool
|
failPush bool
|
||||||
// contents is what a clone of this repository lands, so the fake clone can restore the tree
|
// contents is what a clone of this repository lands, so the fake clone can restore the tree
|
||||||
// Build deliberately removes first.
|
// Build deliberately removes first.
|
||||||
contents map[string]string
|
contents map[string]string
|
||||||
|
// secondary is what a clone of a repository OTHER than the one under test lands, keyed by
|
||||||
|
// that repository's URL — an artifact's own build context, cloned apart from the module.
|
||||||
|
secondary map[string]map[string]string
|
||||||
// stamped is the modification time the clone gives every file. Set differently between two
|
// stamped is the modification time the clone gives every file. Set differently between two
|
||||||
// builds of one commit, because otherwise both land in the same second and a packer that
|
// builds of one commit, because otherwise both land in the same second and a packer that
|
||||||
// carried timestamps would still produce one digest — which is a test that passes for a
|
// carried timestamps would still produce one digest — which is a test that passes for a
|
||||||
@@ -35,13 +41,28 @@ type recorded struct {
|
|||||||
func (r *recorded) run(_ context.Context, dir, name string, args ...string) (string, error) {
|
func (r *recorded) run(_ context.Context, dir, name string, args ...string) (string, error) {
|
||||||
line := name + " " + strings.Join(args, " ")
|
line := name + " " + strings.Join(args, " ")
|
||||||
r.ran = append(r.ran, line)
|
r.ran = append(r.ran, line)
|
||||||
|
r.dirs = append(r.dirs, dir)
|
||||||
|
// A clone may carry `-c` configuration in front of the verb — the credential store — so the
|
||||||
|
// verb is found rather than assumed first.
|
||||||
|
isClone := false
|
||||||
|
for _, a := range args {
|
||||||
|
if a == "clone" {
|
||||||
|
isClone = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case name == "git" && len(args) > 0 && args[0] == "clone":
|
case name == "git" && isClone:
|
||||||
|
repository := args[len(args)-2]
|
||||||
tree := args[len(args)-1]
|
tree := args[len(args)-1]
|
||||||
if err := os.MkdirAll(tree, 0o755); err != nil {
|
if err := os.MkdirAll(tree, 0o755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
for path, body := range r.contents {
|
lands := r.contents
|
||||||
|
if by, is := r.secondary[repository]; is {
|
||||||
|
lands = by
|
||||||
|
}
|
||||||
|
for path, body := range lands {
|
||||||
full := filepath.Join(tree, path)
|
full := filepath.Join(tree, path)
|
||||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -59,7 +80,6 @@ func (r *recorded) run(_ context.Context, dir, name string, args ...string) (str
|
|||||||
case name == "git" && len(args) > 0 && args[0] == "rev-parse":
|
case name == "git" && len(args) > 0 && args[0] == "rev-parse":
|
||||||
return "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff\n", nil
|
return "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff\n", nil
|
||||||
}
|
}
|
||||||
_ = dir
|
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +128,7 @@ func TestABuildProducesAManifestThePinsAreIn(t *testing.T) {
|
|||||||
r, workspace := aRepository(t, withBoth, map[string]string{
|
r, workspace := aRepository(t, withBoth, map[string]string{
|
||||||
"Dockerfile": "FROM scratch", "files/theme.conf": "dark",
|
"Dockerfile": "FROM scratch", "files/theme.conf": "dark",
|
||||||
})
|
})
|
||||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, nil)
|
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -134,7 +154,7 @@ func TestTwoBuildsOfOneCommitProduceOneDigest(t *testing.T) {
|
|||||||
})
|
})
|
||||||
// A year apart, so a packer carrying timestamps cannot accidentally agree.
|
// A year apart, so a packer carrying timestamps cannot accidentally agree.
|
||||||
r.stamped = time.Date(2020+i, time.March, 3, 4, 5, 6, 0, time.UTC)
|
r.stamped = time.Date(2020+i, time.March, 3, 4, 5, 6, 0, time.UTC)
|
||||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil)
|
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -154,7 +174,7 @@ func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) {
|
|||||||
// unreferenced, and indistinguishable from something in use.
|
// unreferenced, and indistinguishable from something in use.
|
||||||
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch"})
|
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch"})
|
||||||
// `files` is missing, so packing the archive fails — after the image would have been pushed.
|
// `files` is missing, so packing the archive fails — after the image would have been pushed.
|
||||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil)
|
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a build with a missing input succeeded")
|
t.Fatal("a build with a missing input succeeded")
|
||||||
}
|
}
|
||||||
@@ -166,7 +186,7 @@ func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) {
|
|||||||
func TestARepositoryWithNoManifestSaysSo(t *testing.T) {
|
func TestARepositoryWithNoManifestSaysSo(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
r := &recorded{contents: map[string]string{"README.md": "nothing to see"}}
|
r := &recorded{contents: map[string]string{"README.md": "nothing to see"}}
|
||||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil)
|
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a repository with nothing saying what it is was built")
|
t.Fatal("a repository with nothing saying what it is was built")
|
||||||
}
|
}
|
||||||
@@ -179,7 +199,7 @@ func TestAModuleThatBuildsNothingStillProducesAManifest(t *testing.T) {
|
|||||||
// Most of what a person installs is configuration.
|
// Most of what a person installs is configuration.
|
||||||
r, workspace := aRepository(t, `{"module":"shell","version":"1","resources":[
|
r, workspace := aRepository(t, `{"module":"shell","version":"1","resources":[
|
||||||
{"id":"rc","type":"file","path":"/etc/zsh/zshrc","content":"setopt"}]}`, nil)
|
{"id":"rc","type":"file","path":"/etc/zsh/zshrc","content":"setopt"}]}`, nil)
|
||||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", "", workspace, nil, Npmrc{}, nil)
|
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -209,7 +229,7 @@ func TestTheTreeIsFreshEveryTime(t *testing.T) {
|
|||||||
if err := os.WriteFile(leftover, []byte("stale"), 0o644); err != nil {
|
if err := os.WriteFile(leftover, []byte("stale"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil); err != nil {
|
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(leftover); err == nil {
|
if _, err := os.Stat(leftover); err == nil {
|
||||||
@@ -222,7 +242,7 @@ func TestABuildThatCannotPushFails(t *testing.T) {
|
|||||||
"Dockerfile": "FROM scratch", "files/a": "b",
|
"Dockerfile": "FROM scratch", "files/a": "b",
|
||||||
})
|
})
|
||||||
r.failPush = true
|
r.failPush = true
|
||||||
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil); err == nil {
|
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil); err == nil {
|
||||||
t.Fatal("a build that could publish nothing reported success")
|
t.Fatal("a build that could publish nothing reported success")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +256,7 @@ func TestAnUpstreamImageIsMirroredRatherThanBuilt(t *testing.T) {
|
|||||||
"resources":[{"id":"db","type":"container","name":"mesh-postgres","artifact":"store"}]}`
|
"resources":[{"id":"db","type":"container","name":"mesh-postgres","artifact":"store"}]}`
|
||||||
|
|
||||||
r, workspace := aRepository(t, mirrors, nil)
|
r, workspace := aRepository(t, mirrors, nil)
|
||||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace, nil, Npmrc{}, nil)
|
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -302,7 +322,7 @@ func TestAModuleIsBuiltFromItsPathWithinTheRepository(t *testing.T) {
|
|||||||
"modules/other/" + ManifestName: `{"module":"other","version":"1"}`,
|
"modules/other/" + ManifestName: `{"module":"other","version":"1"}`,
|
||||||
}}
|
}}
|
||||||
got, err := Build(context.Background(), r.run, r,
|
got, err := Build(context.Background(), r.run, r,
|
||||||
"https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir(), nil, Npmrc{}, nil)
|
"https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -321,7 +341,7 @@ func TestAPathThatLeavesTheRepositoryIsRefused(t *testing.T) {
|
|||||||
for _, escaping := range []string{"../../etc", "/etc"} {
|
for _, escaping := range []string{"../../etc", "/etc"} {
|
||||||
r := &recorded{contents: map[string]string{ManifestName: withBoth}}
|
r := &recorded{contents: map[string]string{ManifestName: withBoth}}
|
||||||
_, err := Build(context.Background(), r.run, r,
|
_, err := Build(context.Background(), r.run, r,
|
||||||
"https://forge.invalid/x.git", escaping, "", t.TempDir(), nil, Npmrc{}, nil)
|
"https://forge.invalid/x.git", escaping, "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("%q was accepted as a module's path", escaping)
|
t.Fatalf("%q was accepted as a module's path", escaping)
|
||||||
}
|
}
|
||||||
@@ -331,3 +351,168 @@ func TestAPathThatLeavesTheRepositoryIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Packaging and source are allowed to live apart** — a module that ships only the recipe for
|
||||||
|
// source that lives in a second repository (the reference route-proxy, packaged in the catalogue
|
||||||
|
// but built from mesh-controller's own repository) names where that source actually is, rather
|
||||||
|
// than vendoring a second copy the two could drift from.
|
||||||
|
func TestAnArtifactWithItsOwnContextIsBuiltFromThere(t *testing.T) {
|
||||||
|
const withContext = `{"module":"route-proxy","version":"1",
|
||||||
|
"build":{"artifacts":[
|
||||||
|
{"name":"server","kind":"image","from":"Dockerfile",
|
||||||
|
"context":{"repository":"https://forge.invalid/source.git","ref":"main"}}]}}`
|
||||||
|
r := &recorded{
|
||||||
|
contents: map[string]string{
|
||||||
|
ManifestName: withContext,
|
||||||
|
// The recipe lives with the packaging, not the source — read from here regardless of
|
||||||
|
// where the build context comes from. FROM scratch declares no base, so what is under
|
||||||
|
// test — where the context comes from — is not entangled with ADR 0097's own checks.
|
||||||
|
"Dockerfile": "FROM scratch\nCOPY go.mod ./\n",
|
||||||
|
},
|
||||||
|
secondary: map[string]map[string]string{
|
||||||
|
// go.mod exists only in the second repository. A build context taken from the wrong
|
||||||
|
// place would never find it, which a real docker build would refuse on — the fake
|
||||||
|
// does not read files, so what is checked below is that the build was even pointed
|
||||||
|
// at the right place, not that COPY would have succeeded.
|
||||||
|
"https://forge.invalid/source.git": {"go.mod": "module route-proxy\n"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := Build(context.Background(), r.run, r,
|
||||||
|
"https://forge.invalid/catalogue.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var clonedSource bool
|
||||||
|
for _, line := range r.ran {
|
||||||
|
if strings.HasPrefix(line, "git clone") && strings.Contains(line, "https://forge.invalid/source.git") {
|
||||||
|
clonedSource = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !clonedSource {
|
||||||
|
t.Fatalf("the artifact's own context was never cloned: %v", r.ran)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildIndex := -1
|
||||||
|
for i, line := range r.ran {
|
||||||
|
if strings.HasPrefix(line, "docker build ") {
|
||||||
|
buildIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if buildIndex == -1 {
|
||||||
|
t.Fatal("no docker build was run")
|
||||||
|
}
|
||||||
|
build := r.ran[buildIndex]
|
||||||
|
buildDir := r.dirs[buildIndex]
|
||||||
|
|
||||||
|
if !strings.Contains(buildDir, "context-server") {
|
||||||
|
t.Errorf("docker build ran from %q, not the artifact's own cloned context", buildDir)
|
||||||
|
}
|
||||||
|
recipe := strings.SplitN(strings.SplitN(build, "-f ", 2)[1], " ", 2)[0]
|
||||||
|
if !filepath.IsAbs(recipe) {
|
||||||
|
t.Errorf("the recipe %q is not an absolute path, so it is read relative to whatever "+
|
||||||
|
"directory the build context moved to rather than where it actually is", recipe)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(recipe, string(filepath.Separator)+"Dockerfile") {
|
||||||
|
t.Errorf("the recipe is not the module's own Dockerfile: %q", recipe)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(build, " .") {
|
||||||
|
t.Errorf("the build was not given a context: %s", build)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The forge credential is offered through git's own credential store — a file, never argv — and
|
||||||
|
// git decides when it applies. What is checked: the clone names the store, the secret never
|
||||||
|
// appears in a command line, and the file holds exactly the URL at 0600.
|
||||||
|
func TestABuildOffersTheForgesCredentialThroughGitsOwnStore(t *testing.T) {
|
||||||
|
r, workspace := aRepository(t, withBoth, map[string]string{
|
||||||
|
"Dockerfile": "FROM scratch", "files/theme.conf": "dark",
|
||||||
|
})
|
||||||
|
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "",
|
||||||
|
workspace, nil, Npmrc{},
|
||||||
|
GitCredential{URL: "http://mesh_novox_builder:sw0rdfi5h@forge.invalid:20000"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stored := filepath.Join(workspace, "git-credentials")
|
||||||
|
clone := r.ran[0]
|
||||||
|
if !strings.Contains(clone, "credential.helper=store --file="+stored) {
|
||||||
|
t.Fatalf("the clone does not name the credential store: %s", clone)
|
||||||
|
}
|
||||||
|
for _, line := range r.ran {
|
||||||
|
if strings.Contains(line, "sw0rdfi5h") {
|
||||||
|
t.Fatalf("the secret is in a command line, readable by anything that can list processes: %s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(stored)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(raw)) != "http://mesh_novox_builder:sw0rdfi5h@forge.invalid:20000" {
|
||||||
|
t.Fatalf("the store does not hold the credential as given: %q", raw)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(stored)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("the credential file is readable beyond its owner: %v", info.Mode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a credential, a clone is exactly the invocation it always was, and no credential file
|
||||||
|
// appears — the builder a mesh of public repositories runs is unchanged.
|
||||||
|
func TestABuildWithNoCredentialClonesExactlyAsBefore(t *testing.T) {
|
||||||
|
r, workspace := aRepository(t, withBoth, map[string]string{
|
||||||
|
"Dockerfile": "FROM scratch", "files/theme.conf": "dark",
|
||||||
|
})
|
||||||
|
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "",
|
||||||
|
workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(r.ran[0], "git clone --quiet ") {
|
||||||
|
t.Fatalf("a credential-less clone grew flags: %s", r.ran[0])
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(workspace, "git-credentials")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("a credential file was written with no credential to put in it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An artifact's own context is cloned with the same offer: a private module whose context is a
|
||||||
|
// second private repository on the same forge builds, and the secret still never reaches argv.
|
||||||
|
func TestAContextCloneCarriesTheSameCredentialStore(t *testing.T) {
|
||||||
|
const withContext = `{"module":"route-proxy","version":"1",
|
||||||
|
"build":{"artifacts":[
|
||||||
|
{"name":"server","kind":"image","from":"Dockerfile",
|
||||||
|
"context":{"repository":"https://forge.invalid/source.git","ref":"main"}}]}}`
|
||||||
|
r := &recorded{
|
||||||
|
contents: map[string]string{
|
||||||
|
ManifestName: withContext,
|
||||||
|
"Dockerfile": "FROM scratch\nCOPY go.mod ./\n",
|
||||||
|
},
|
||||||
|
secondary: map[string]map[string]string{
|
||||||
|
"https://forge.invalid/source.git": {"go.mod": "module route-proxy\n"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
workspace := t.TempDir()
|
||||||
|
_, err := Build(context.Background(), r.run, r,
|
||||||
|
"https://forge.invalid/catalogue.git", "", "", workspace, nil, Npmrc{},
|
||||||
|
GitCredential{URL: "https://builder:s3cret@forge.invalid"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stored := filepath.Join(workspace, "git-credentials")
|
||||||
|
var contextClone string
|
||||||
|
for _, line := range r.ran {
|
||||||
|
if strings.Contains(line, "clone") && strings.Contains(line, "source.git") {
|
||||||
|
contextClone = line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if contextClone == "" {
|
||||||
|
t.Fatalf("the context was never cloned: %v", r.ran)
|
||||||
|
}
|
||||||
|
if !strings.Contains(contextClone, "credential.helper=store --file="+stored) {
|
||||||
|
t.Fatalf("the context clone does not name the credential store: %s", contextClone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestABundleIsCompiledAndPackedWithNoDockerfile(t *testing.T) {
|
|||||||
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
|
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
|
||||||
|
|
||||||
got, err := Build(context.Background(), compiling{r}.run, r,
|
got, err := Build(context.Background(), compiling{r}.run, r,
|
||||||
"https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, nil)
|
"https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("a module with a language and no Dockerfile did not build: %v", err)
|
t.Fatalf("a module with a language and no Dockerfile did not build: %v", err)
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ func TestABundleWhoseToolchainIsNotHeldIsRefusedFirst(t *testing.T) {
|
|||||||
r, workspace := aRepository(t, aBundle, map[string]string{"index.ts": "console.log(1)"})
|
r, workspace := aRepository(t, aBundle, map[string]string{"index.ts": "console.log(1)"})
|
||||||
|
|
||||||
_, err := Build(context.Background(), compiling{r}.run, r,
|
_, err := Build(context.Background(), compiling{r}.run, r,
|
||||||
"https://forge.invalid/greeter.git", "", "", workspace, nil, Npmrc{}, nil)
|
"https://forge.invalid/greeter.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a bundle was built with no toolchain to compile it in")
|
t.Fatal("a bundle was built with no toolchain to compile it in")
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ func TestABundleInAnUnknownLanguageIsRefused(t *testing.T) {
|
|||||||
|
|
||||||
_, err := Build(context.Background(), compiling{r}.run, r,
|
_, err := Build(context.Background(), compiling{r}.run, r,
|
||||||
"https://forge.invalid/greeter.git", "", "", workspace,
|
"https://forge.invalid/greeter.git", "", "", workspace,
|
||||||
map[string]string{"mesh-tools/build": "registry.invalid/x@sha256:" + strings.Repeat("c", 64)}, Npmrc{}, nil)
|
map[string]string{"mesh-tools/build": "registry.invalid/x@sha256:" + strings.Repeat("c", 64)}, Npmrc{}, GitCredential{}, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a language nothing can compile was accepted")
|
t.Fatal("a language nothing can compile was accepted")
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ func TestTwoBundlesInOneModuleArePackedSeparately(t *testing.T) {
|
|||||||
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
|
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
|
||||||
|
|
||||||
got, err := Build(context.Background(), compiling{r}.run, r,
|
got, err := Build(context.Background(), compiling{r}.run, r,
|
||||||
"https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, nil)
|
"https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("a module with two bundles did not build: %v", err)
|
t.Fatalf("a module with two bundles did not build: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func TestAnImageBuildGetsTheCredentialInTheContextAndHostNetwork(t *testing.T) {
|
|||||||
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch\nCOPY .npmrc ./", "files/x": "y"})
|
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch\nCOPY .npmrc ./", "files/x": "y"})
|
||||||
n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"}
|
n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"}
|
||||||
if _, err := Build(context.Background(), r.run, r,
|
if _, err := Build(context.Background(), r.run, r,
|
||||||
"https://forge.invalid/meshboard.git", "", "", workspace, nil, n, nil); err != nil {
|
"https://forge.invalid/meshboard.git", "", "", workspace, nil, n, GitCredential{}, nil); err != nil {
|
||||||
t.Fatalf("the build failed: %v", err)
|
t.Fatalf("the build failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ func TestAnImageBuildGetsTheCredentialInTheContextAndHostNetwork(t *testing.T) {
|
|||||||
func TestAnImageBuildWithoutACredentialGetsNoHostNetwork(t *testing.T) {
|
func TestAnImageBuildWithoutACredentialGetsNoHostNetwork(t *testing.T) {
|
||||||
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch", "files/x": "y"})
|
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch", "files/x": "y"})
|
||||||
if _, err := Build(context.Background(), r.run, r,
|
if _, err := Build(context.Background(), r.run, r,
|
||||||
"https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, nil); err != nil {
|
"https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil); err != nil {
|
||||||
t.Fatalf("the build failed: %v", err)
|
t.Fatalf("the build failed: %v", err)
|
||||||
}
|
}
|
||||||
for _, line := range r.ran {
|
for _, line := range r.ran {
|
||||||
@@ -127,7 +127,7 @@ func TestAPackageIsBuiltOnAPublicBaseAndPublishedByVersion(t *testing.T) {
|
|||||||
})
|
})
|
||||||
n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"}
|
n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"}
|
||||||
got, err := Build(context.Background(), r.run, r,
|
got, err := Build(context.Background(), r.run, r,
|
||||||
"https://forge.invalid/mesh-sdk.git", "", "", workspace, nil, n, nil)
|
"https://forge.invalid/mesh-sdk.git", "", "", workspace, nil, n, GitCredential{}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("the package did not build: %v", err)
|
t.Fatalf("the package did not build: %v", err)
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ func TestAPackageWithNoRegistryIsRefused(t *testing.T) {
|
|||||||
"package.json": `{"name":"@novox/mesh-sdk","version":"0.1.0"}`,
|
"package.json": `{"name":"@novox/mesh-sdk","version":"0.1.0"}`,
|
||||||
})
|
})
|
||||||
_, err := Build(context.Background(), r.run, r,
|
_, err := Build(context.Background(), r.run, r,
|
||||||
"https://forge.invalid/mesh-sdk.git", "", "", workspace, nil, Npmrc{}, nil)
|
"https://forge.invalid/mesh-sdk.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a package built with no registry to publish to, silently")
|
t.Fatal("a package built with no registry to publish to, silently")
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ func TestAnImageThatDoesNotAskForTheCredentialDoesNotGetIt(t *testing.T) {
|
|||||||
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch\nCOPY . .", "files/x": "y"})
|
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch\nCOPY . .", "files/x": "y"})
|
||||||
n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"}
|
n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"}
|
||||||
if _, err := Build(context.Background(), r.run, r,
|
if _, err := Build(context.Background(), r.run, r,
|
||||||
"https://forge.invalid/meshboard.git", "", "", workspace, nil, n, nil); err != nil {
|
"https://forge.invalid/meshboard.git", "", "", workspace, nil, n, GitCredential{}, nil); err != nil {
|
||||||
t.Fatalf("the build failed: %v", err)
|
t.Fatalf("the build failed: %v", err)
|
||||||
}
|
}
|
||||||
for _, line := range r.ran {
|
for _, line := range r.ran {
|
||||||
|
|||||||
@@ -911,30 +911,53 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||||
}
|
}
|
||||||
composeName(values, r.PublicDomain)
|
composeName(values, r.PublicDomain, r.At)
|
||||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||||
}
|
}
|
||||||
|
// Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
|
||||||
|
// object store's data API and its console are two different public names from one module,
|
||||||
|
// not one. Never in `granted` — a route names a host, not a credential — so every local
|
||||||
|
// name always reaches the provider from here.
|
||||||
|
for _, to := range sortedKeys(m.ContributesMany) {
|
||||||
|
for _, local := range sortedKeys(m.ContributesMany[to]) {
|
||||||
|
values, err := settle(m.ContributesMany[to][local], settings[m.Module], nil,
|
||||||
|
m.Module+" contributing "+local+" to "+to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
||||||
|
}
|
||||||
|
composeName(values, r.PublicDomain, r.At)
|
||||||
|
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// composeName joins a contribution's label with a node's public domain, in place (novox/hq ADR
|
// composeName joins a contribution's label with a node's public domain, and separately with its
|
||||||
// 0056).
|
// private one, in place (novox/hq ADR 0056).
|
||||||
//
|
//
|
||||||
// **The whole of what the mesh does with a route's name: join two given strings.** A contribution
|
// **The whole of what the mesh does with a route's name: join two given strings — twice.** A
|
||||||
// carries a `label` — the subdomain its operator chose — and the node carries its public domain;
|
// contribution carries a `label` — the subdomain its operator chose — and the node carries its
|
||||||
// the granted name is `<label>.<public-domain>` and the mesh interprets neither half. It runs on
|
// public domain and its own private-network address; the granted names are `<label>.<public-domain>`
|
||||||
// any contribution carrying a label, not only a route's, because the mesh does not know what a
|
// and `<label>.<internal-domain>`, and the mesh interprets none of the halves. It runs on any
|
||||||
|
// contribution carrying a label, not only a route's, because the mesh does not know what a
|
||||||
// provision means — a name it can compose from parts it was given is the point, whatever the
|
// provision means — a name it can compose from parts it was given is the point, whatever the
|
||||||
// provision is called.
|
// provision is called.
|
||||||
//
|
//
|
||||||
|
// **The internal name is not a security boundary.** A predecessor proxy that answered both a
|
||||||
|
// public and a private-network hostname for the same route did so as a convenience — reaching a
|
||||||
|
// service over the VPN without a public TLS round trip — not as an access control, and composing
|
||||||
|
// the same alias here restores that convenience rather than adding one. A route with no internal
|
||||||
|
// domain to compose against (a node not on the private network) gets no internal name, the same as
|
||||||
|
// it gets no public one with no public domain.
|
||||||
|
//
|
||||||
// **Additive, so an unmigrated catalogue still works.** A contribution that already carries a full
|
// **Additive, so an unmigrated catalogue still works.** A contribution that already carries a full
|
||||||
// `name` and no `label` is left exactly as it is: the catalogue can migrate module by module while
|
// `name` and no `label` is left exactly as it is: the catalogue can migrate module by module while
|
||||||
// the running mesh keeps serving the full names it has. And a labelled contribution on a node with
|
// the running mesh keeps serving the full names it has. And a labelled contribution on a node with
|
||||||
// no public domain composes nothing — there is nothing to join it to — which reads downstream as a
|
// no public domain composes nothing — there is nothing to join it to — which reads downstream as a
|
||||||
// route that named no host, the same as it would have before this existed.
|
// route that named no host, the same as it would have before this existed.
|
||||||
func composeName(values map[string]any, publicDomain string) {
|
func composeName(values map[string]any, publicDomain, internalDomain string) {
|
||||||
if values == nil || publicDomain == "" {
|
if values == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, already := values["name"]; already {
|
if _, already := values["name"]; already {
|
||||||
@@ -947,14 +970,25 @@ func composeName(values map[string]any, publicDomain string) {
|
|||||||
if !ok || strings.TrimSpace(label) == "" {
|
if !ok || strings.TrimSpace(label) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(label) == "@" {
|
trimmed := strings.TrimSpace(label)
|
||||||
// The apex: a module served at the bare public domain, no subdomain — the zone-file
|
if trimmed == "@" {
|
||||||
// convention `@`. Composes to the domain itself, so a node's own site is a label like any
|
// The apex: a module served at the bare domain, no subdomain — the zone-file convention
|
||||||
// other rather than the one route that must still carry a full name.
|
// `@`. Composes to the domain itself, so a node's own site is a label like any other rather
|
||||||
|
// than the one route that must still carry a full name.
|
||||||
|
if publicDomain != "" {
|
||||||
values["name"] = publicDomain
|
values["name"] = publicDomain
|
||||||
|
}
|
||||||
|
if internalDomain != "" {
|
||||||
|
values["internal-name"] = internalDomain
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
values["name"] = strings.TrimSpace(label) + "." + publicDomain
|
if publicDomain != "" {
|
||||||
|
values["name"] = trimmed + "." + publicDomain
|
||||||
|
}
|
||||||
|
if internalDomain != "" {
|
||||||
|
values["internal-name"] = trimmed + "." + internalDomain
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// receivedFile is the file a provider is given its consumers' contributions in.
|
// receivedFile is the file a provider is given its consumers' contributions in.
|
||||||
@@ -1105,17 +1139,34 @@ func boundFile(n Needed, path, as string) (map[string]any, error) {
|
|||||||
// arrangement refused is the ordinary one. A node running eight services against one database is
|
// arrangement refused is the ordinary one. A node running eight services against one database is
|
||||||
// not an edge case; it is what a machine looks like. Now each consumer has its own credential and
|
// not an edge case; it is what a machine looks like. Now each consumer has its own credential and
|
||||||
// there is nothing left to refuse.
|
// there is nothing left to refuse.
|
||||||
|
//
|
||||||
|
// **One credential, even where a module contributes several times.** A module may answer one
|
||||||
|
// requirement more than once (ADR 0094's sibling for `contributes`) — an object store's data API
|
||||||
|
// and its console are two different names, not one. There is still only one `Needed` for it, one
|
||||||
|
// credential minted, one grant to settle: a pair credential is not a place to put a label or a
|
||||||
|
// port. So where several of this module's contributions reach the same requirement, none of them
|
||||||
|
// is "the" value — settling to the first, arbitrarily, would hand the grant one contribution's
|
||||||
|
// values under a credential the OTHER contribution's consumer never sees, and would collide with
|
||||||
|
// that contribution's own entry from contributions() besides. Empty values, still granted: the
|
||||||
|
// module asked, gets its credential, and each named contribution reaches the provider on its own.
|
||||||
func (r Resolution) ContributionsFrom(requirement, module string, settings SettingsBy) (
|
func (r Resolution) ContributionsFrom(requirement, module string, settings SettingsBy) (
|
||||||
map[string]any, bool, error) {
|
map[string]any, bool, error) {
|
||||||
all, err := r.contributions(settings, nil, nil)
|
all, err := r.contributions(settings, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
var mine []map[string]any
|
||||||
for _, g := range all[requirement] {
|
for _, g := range all[requirement] {
|
||||||
if g.From == module {
|
if g.From == module {
|
||||||
return g.Values, true, nil
|
mine = append(mine, g.Values)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(mine) == 1 {
|
||||||
|
return mine[0], true, nil
|
||||||
|
}
|
||||||
|
if len(mine) > 1 {
|
||||||
|
return map[string]any{}, true, nil
|
||||||
|
}
|
||||||
// It contributes no payload — but a require-only consumer of a parameterless provision (one whose
|
// It contributes no payload — but a require-only consumer of a parameterless provision (one whose
|
||||||
// `serves` names no consumer-supplied key: `redis-cache`, `amqp`) still ASKS for it and must be
|
// `serves` names no consumer-supplied key: `redis-cache`, `amqp`) still ASKS for it and must be
|
||||||
// granted a credential. Keying "asks" on contributions alone marked those grants withdrawn
|
// granted a credential. Keying "asks" on contributions alone marked those grants withdrawn
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -85,9 +84,8 @@ func TestTheFilterModuleNeverFlushesTheRuleset(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The package registry's port is the node's, like every other foundation port (novox/hq
|
// The package registry's port is the node's, like every other foundation port (novox/hq
|
||||||
// 04-ISSUES/085, ADR 0100). Two halves, because the forge is reached two ways: through what the
|
// 04-ISSUES/085, ADR 0100). The forge is reached through what it says it serves, and consumers —
|
||||||
// module that serves it says it serves, and — for the genesis window, before any module provides
|
// the builder among them — are told that, rather than carrying a number of their own.
|
||||||
// `package-registry` at all — through the one binding the builder carries instead of resolving.
|
|
||||||
|
|
||||||
func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) {
|
func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) {
|
||||||
forge := catalogueManifest(t, "gitea")
|
forge := catalogueManifest(t, "gitea")
|
||||||
@@ -104,96 +102,66 @@ func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) {
|
|||||||
|
|
||||||
// And every consumer of the package registry is told where the machine actually put it,
|
// And every consumer of the package registry is told where the machine actually put it,
|
||||||
// because that is read from what the forge serves rather than written in the consumer.
|
// because that is read from what the forge serves rather than written in the consumer.
|
||||||
if got := ServedOn(forge, "package-registry", given)["port"]; got != 3100 {
|
if got := ServedOn(forge, "npm-package-registry", given)["port"]; got != 3100 {
|
||||||
t.Errorf("the package registry is served on %v, not the port this node gave it", got)
|
t.Errorf("the package registry is served on %v, not the port this node gave it", got)
|
||||||
}
|
}
|
||||||
if got := ServedOn(forge, "package-registry", nil)["port"]; got != float64(3000) {
|
if got := ServedOn(forge, "npm-package-registry", nil)["port"]; got != float64(3000) {
|
||||||
t.Errorf("without a setting the forge serves %v, not the catalogue's port", got)
|
t.Errorf("without a setting the forge serves %v, not the catalogue's port", got)
|
||||||
}
|
}
|
||||||
|
// And so is where a repository on it is cloned from (novox/hq ADR 0111), for the same reason:
|
||||||
|
// a build composes the URL from what the forge serves, so a given port is a followed port.
|
||||||
|
if got := ServedOn(forge, "git", given)["port"]; got != 3100 {
|
||||||
|
t.Errorf("git is served on %v, not the port this node gave the forge", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bindingIn is the package binding the builder carries, as the machine would receive it.
|
// **The builder requires the registry the npm seat delivers, and carries no binding of its own.**
|
||||||
func bindingIn(t *testing.T, m Manifest, layers []Layer) map[string]any {
|
//
|
||||||
t.Helper()
|
// It used to carry a hand-written binding because nothing provided a package registry to resolve
|
||||||
for _, r := range m.Resources {
|
// one from at genesis. The catalogue now requires it like any consumer, and ADR 0110 makes the
|
||||||
if fmt.Sprint(r["id"]) != "package-binding" {
|
// seat's holder the answer when more than one module provides it — so a carried copy would be a
|
||||||
continue
|
// second answer to the same question, free to drift from the first. Asserted gone, not merely
|
||||||
}
|
// unused.
|
||||||
settled, err := ApplySettings(r, layers)
|
func TestTheBuilderRequiresTheRegistryTheNpmSeatDelivers(t *testing.T) {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("the builder's package binding refused %v: %v", layers, err)
|
|
||||||
}
|
|
||||||
if settled["merge"] != nil || settled["protected"] != nil {
|
|
||||||
t.Fatal("the host would be sent fields it does not know")
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(fmt.Sprint(settled["content"])), &out); err != nil {
|
|
||||||
t.Fatalf("the builder's package binding is not a binding: %v", err)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
t.Fatal("the builder carries no package binding")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheBuildersCarriedPackageBindingTakesThePortFromTheNode(t *testing.T) {
|
|
||||||
builder := catalogueManifest(t, "builder")
|
builder := catalogueManifest(t, "builder")
|
||||||
|
seat, _ := SeatNamed("npm-package-registry")
|
||||||
// Nothing set: the catalogue's own number, which is what a mesh raised on the defaults uses.
|
var requires bool
|
||||||
serves := bindingIn(t, builder, nil)["serves"].(map[string]any)
|
for _, r := range builder.Requires {
|
||||||
if serves["port"] != float64(3000) {
|
requires = requires || r == seat.Delivers
|
||||||
t.Fatalf("the builder's binding defaults to %v", serves["port"])
|
|
||||||
}
|
}
|
||||||
|
if !requires {
|
||||||
// Given a port, the binding dials it — and the rest of what the forge serves survives, because
|
t.Fatalf("the builder does not require %q: %v", seat.Delivers, builder.Requires)
|
||||||
// a setting is merged into the module's own values rather than replacing them.
|
|
||||||
moved := bindingIn(t, builder, []Layer{{From: "anchor",
|
|
||||||
Values: map[string]any{"serves": map[string]any{"port": float64(3100)}}}})
|
|
||||||
got := moved["serves"].(map[string]any)
|
|
||||||
if got["port"] != float64(3100) {
|
|
||||||
t.Errorf("the builder dials %v, not the port this node gave the package registry", got["port"])
|
|
||||||
}
|
}
|
||||||
if got["scheme"] != "http" || got["npm-path"] != "/api/packages/novox/npm/" {
|
if builder.Binds[seat.Delivers] == "" {
|
||||||
t.Errorf("setting the port lost the rest of what the forge serves: %v", got)
|
t.Errorf("the builder is not told where the registry is: binds %v", builder.Binds)
|
||||||
}
|
}
|
||||||
if moved["as"] != "mesh-builder" || moved["from"] != "gitea" {
|
|
||||||
t.Errorf("setting the port changed who the binding is with: %v", moved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The two halves are one number. The builder carries a binding because at genesis nothing provides
|
|
||||||
// `package-registry` to resolve one from; the day the forge is a module, the same consumer is told
|
|
||||||
// what the forge serves. They have to start from the same port, or a mesh raised on the defaults
|
|
||||||
// dials one number before the forge is assigned and another after.
|
|
||||||
func TestTheBuildersCarriedBindingStartsWhereTheForgeServes(t *testing.T) {
|
|
||||||
forge := ServedOn(catalogueManifest(t, "gitea"), "package-registry", nil)
|
|
||||||
carried := bindingIn(t, catalogueManifest(t, "builder"), nil)["serves"].(map[string]any)
|
|
||||||
for _, key := range []string{"port", "scheme", "npm-path"} {
|
|
||||||
if fmt.Sprint(forge[key]) != fmt.Sprint(carried[key]) {
|
|
||||||
t.Errorf("the forge serves %s %v and the builder's carried binding says %v — the two "+
|
|
||||||
"halves of the same registry have drifted apart in the catalogue",
|
|
||||||
key, forge[key], carried[key])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheBuildersPackageBindingKeepsItsIdentity(t *testing.T) {
|
|
||||||
builder := catalogueManifest(t, "builder")
|
|
||||||
// `at` above all: a setting that moves it points the builder, and the registry password it
|
|
||||||
// sends as basic auth, at a host somebody else chose.
|
|
||||||
for _, key := range []string{"provision", "from", "at", "as"} {
|
|
||||||
var refused error
|
|
||||||
for _, r := range builder.Resources {
|
for _, r := range builder.Resources {
|
||||||
if fmt.Sprint(r["id"]) != "package-binding" {
|
if fmt.Sprint(r["id"]) == "package-binding" {
|
||||||
continue
|
t.Fatal("the builder carries its own package binding beside the one the mesh resolves")
|
||||||
}
|
}
|
||||||
_, refused = ApplySettings(r, []Layer{{From: "anchor",
|
|
||||||
Values: map[string]any{key: "something else"}}})
|
|
||||||
}
|
}
|
||||||
if refused == nil {
|
|
||||||
t.Errorf("%q can be set on the builder's package binding, which is not a port but who "+
|
|
||||||
"the binding is with", key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The forge holds the seats it answers for (novox/hq ADR 0110, 0111), parsed by the real parser —
|
||||||
|
// which refuses a delivering seat claimed by a module that does not provide what it delivers.
|
||||||
|
func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) {
|
||||||
|
forge := catalogueManifest(t, "gitea")
|
||||||
|
holds := map[string]bool{}
|
||||||
|
for _, c := range forge.Claims {
|
||||||
|
holds[c.Name] = true
|
||||||
|
}
|
||||||
|
for _, seat := range []string{"npm-package-registry", "git"} {
|
||||||
|
if !holds[seat] {
|
||||||
|
t.Errorf("gitea does not claim the %s seat: %+v", seat, forge.Claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
git := ServedOn(forge, "git", nil)
|
||||||
|
if git["scheme"] != "http" || git["port"] != float64(3000) {
|
||||||
|
t.Errorf("gitea serves nothing a clone URL can be composed from: %v", git)
|
||||||
|
}
|
||||||
|
npm := ServedOn(forge, "npm-package-registry", nil)
|
||||||
|
if npm["npm-path"] != "/api/packages/novox/npm/" {
|
||||||
|
t.Errorf("gitea no longer says where its npm registry is: %v", npm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,27 +219,13 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **And the port the forge publishes the long way is the node's too** (novox/hq ADR 0100).
|
// gitea's own sshd is unmodified — the module's own internal port is 22, the number in
|
||||||
//
|
// `listens`, the same convention every other module in the catalogue uses (its internal port,
|
||||||
// The forge's ssh port is written `2222:22` — the machine's own daemon holds 22, so the module
|
// not an invented identity). Composed from the manifest in the catalogue beside this checkout,
|
||||||
// takes 2222 and says so in `listens`. A node whose predecessor served git on another number
|
// because what the mesh publishes is a fact about what the module actually writes.
|
||||||
// cannot be told to leave it there unless the setting may name the machine side of that mapping,
|
func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any {
|
||||||
// which is the number the manifest itself uses everywhere else. Composed from the manifest in the
|
t.Helper()
|
||||||
// catalogue beside this checkout, because what the mesh can move is a fact about what the module
|
|
||||||
// actually writes.
|
|
||||||
func TestTheForgesSshPortIsGivenByTheNumberTheForgeCallsIt(t *testing.T) {
|
|
||||||
forge := catalogueManifest(t, "gitea")
|
forge := catalogueManifest(t, "gitea")
|
||||||
given, err := GivenPorts(forge, []Layer{{From: "anchor",
|
|
||||||
Values: map[string]any{PortsSetting: map[string]any{"2222": float64(222)}}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("the forge's ssh port cannot be given on a node: %v", err)
|
|
||||||
}
|
|
||||||
// Under the number the module listens on — 2222, the machine side of its mapping — which is
|
|
||||||
// the number the plan, the filter, the openings and the consumer all ask for. One entry.
|
|
||||||
if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) {
|
|
||||||
t.Fatalf("the forge was given %v, and it names its ssh port %v", given, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
resolved, err := forge.Resolve([]Built{{
|
resolved, err := forge.Resolve([]Built{{
|
||||||
Name: "runtime", Kind: ArtifactImage,
|
Name: "runtime", Kind: ArtifactImage,
|
||||||
Reference: "registry.example/gitea-runtime@sha256:" + strings.Repeat("a", 64),
|
Reference: "registry.example/gitea-runtime@sha256:" + strings.Repeat("a", 64),
|
||||||
@@ -286,9 +240,13 @@ func TestTheForgesSshPortIsGivenByTheNumberTheForgeCallsIt(t *testing.T) {
|
|||||||
{Name: "secret", For: "gitea", From: "anchor", Local: "internal-token", Sealed: "sealed-token"},
|
{Name: "secret", For: "gitea", From: "anchor", Local: "internal-token", Sealed: "sealed-token"},
|
||||||
{Name: "secret", For: "gitea", From: "anchor", Local: "admin", Sealed: "sealed-admin"},
|
{Name: "secret", For: "gitea", From: "anchor", Local: "admin", Sealed: "sealed-admin"},
|
||||||
}}
|
}}
|
||||||
|
givenPorts := map[int]int{3000: 3000}
|
||||||
|
for k, v := range given {
|
||||||
|
givenPorts[k] = v
|
||||||
|
}
|
||||||
out, err := r.Declaration(Rendering{
|
out, err := r.Declaration(Rendering{
|
||||||
Needed: map[string]map[string]string{"gitea": {"broker": "sealed-broker"}},
|
Needed: map[string]map[string]string{"gitea": {"broker": "sealed-broker"}},
|
||||||
Ports: map[string]map[int]int{"gitea": {3000: 3000, 2222: 222}},
|
Ports: map[string]map[int]int{"gitea": givenPorts},
|
||||||
Given: map[string]map[int]int{"gitea": given},
|
Given: map[string]map[int]int{"gitea": given},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -298,8 +256,48 @@ func TestTheForgesSshPortIsGivenByTheNumberTheForgeCallsIt(t *testing.T) {
|
|||||||
if server == nil {
|
if server == nil {
|
||||||
t.Fatalf("the forge's own container is not in the declaration: %v", out)
|
t.Fatalf("the forge's own container is not in the declaration: %v", out)
|
||||||
}
|
}
|
||||||
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "222:22") ||
|
return server
|
||||||
strings.Contains(published, "2222:22") {
|
}
|
||||||
|
|
||||||
|
// **The forge publishes ssh at the mesh's own fixed convention by default** (novox/hq ADR 0100).
|
||||||
|
//
|
||||||
|
// `222` is the mesh's own public convention for the forge's ssh, written directly in the
|
||||||
|
// manifest's `ports` — every node the forge has run on used the same number, so it needs no
|
||||||
|
// per-node setting to reach it.
|
||||||
|
func TestTheForgesSshPortIsTheMeshsFixedConventionByDefault(t *testing.T) {
|
||||||
|
forge := catalogueManifest(t, "gitea")
|
||||||
|
// Nothing was given — no node moved this port — which is the ordinary answer: the mesh only
|
||||||
|
// reports what a setting moved, and the manifest's own `222:22` needs no move to be reached.
|
||||||
|
given, err := GivenPorts(forge, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the forge's ssh port cannot be given on a node: %v", err)
|
||||||
|
}
|
||||||
|
if len(given) != 0 {
|
||||||
|
t.Fatalf("nothing moved the forge's ssh port, yet it was given %v", given)
|
||||||
|
}
|
||||||
|
server := declaredGiteaSsh(t, given)
|
||||||
|
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "222:22") {
|
||||||
|
t.Fatalf("the forge is published on %v, not its own fixed convention", server["ports"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A node whose predecessor served git on a different number can still be told to leave it
|
||||||
|
// there.** The setting names the port the module itself listens on — 22, gitea's own sshd, the
|
||||||
|
// same number `listens` uses — not the mesh's own default machine-side number, so moving it does
|
||||||
|
// not require guessing what the manifest happens to default to.
|
||||||
|
func TestANodeMayGiveTheForgesSshPortADifferentNumber(t *testing.T) {
|
||||||
|
forge := catalogueManifest(t, "gitea")
|
||||||
|
given, err := GivenPorts(forge, []Layer{{From: "anchor",
|
||||||
|
Values: map[string]any{PortsSetting: map[string]any{"22": float64(9022)}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the forge's ssh port cannot be moved on a node: %v", err)
|
||||||
|
}
|
||||||
|
if want := map[int]int{22: 9022}; !reflect.DeepEqual(given, want) {
|
||||||
|
t.Fatalf("the forge was given %v, and the setting named %v", given, want)
|
||||||
|
}
|
||||||
|
server := declaredGiteaSsh(t, given)
|
||||||
|
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "9022:22") ||
|
||||||
|
strings.Contains(published, "222:22") {
|
||||||
t.Fatalf("the forge is published on %v, not the port this node gave it", server["ports"])
|
t.Fatalf("the forge is published on %v, not the port this node gave it", server["ports"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,18 @@ type Manifest struct {
|
|||||||
// module that had to say both would eventually say one.
|
// module that had to say both would eventually say one.
|
||||||
Contributes map[string]map[string]any `json:"contributes,omitempty"`
|
Contributes map[string]map[string]any `json:"contributes,omitempty"`
|
||||||
|
|
||||||
|
// ContributesMany is the same key, `contributes`, where a module tells one provider several
|
||||||
|
// things under local names — `"route": {"api": {"label": "files-api", "port": 9000}, "console":
|
||||||
|
// {"label": "files", "port": 9001}}` — because a module may answer one requirement more than
|
||||||
|
// once: an object store with a data API and a console are two different public names, not one
|
||||||
|
// (novox/hq ADR 0094's sibling for `contributes` rather than `secrets` — "a module may need more
|
||||||
|
// than one value from a provider that gives one per pair" applies exactly as well to what a
|
||||||
|
// module gives a provider as to what it keeps from one). Each local name is a contribution of
|
||||||
|
// its own, reaching the provider as its own entry in the file it receives.
|
||||||
|
//
|
||||||
|
// Filled from the manifest's `contributes` object by UnmarshalJSON; never written by hand.
|
||||||
|
ContributesMany map[string]map[string]map[string]any `json:"-"`
|
||||||
|
|
||||||
// Receives is where this module wants its consumers' contributions written, per requirement
|
// Receives is where this module wants its consumers' contributions written, per requirement
|
||||||
// it provides.
|
// it provides.
|
||||||
//
|
//
|
||||||
@@ -435,6 +447,18 @@ type BuildsOn struct {
|
|||||||
Image string `json:"image,omitempty"`
|
Image string `json:"image,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArtifactContext names the repository an image artifact's build context is cloned from, when
|
||||||
|
// that is not this module's own repository.
|
||||||
|
type ArtifactContext struct {
|
||||||
|
// Repository is cloned fresh, the same way the module's own repository is — a working tree
|
||||||
|
// nothing has touched, so what was built is reproducible from the two commits named rather
|
||||||
|
// than from whatever a previous build happened to leave behind.
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
// Ref is the branch, tag or commit of that repository to build. Empty means its own default
|
||||||
|
// branch — the same meaning an empty module ref already has.
|
||||||
|
Ref string `json:"ref,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Artifact is one thing built from a module's source.
|
// Artifact is one thing built from a module's source.
|
||||||
type Artifact struct {
|
type Artifact struct {
|
||||||
// Name is how resources refer to it. Local to the module.
|
// Name is how resources refer to it. Local to the module.
|
||||||
@@ -454,6 +478,17 @@ type Artifact struct {
|
|||||||
// Empty means the whole recipe, which is what a module with one image says by saying nothing.
|
// Empty means the whole recipe, which is what a module with one image says by saying nothing.
|
||||||
Target string `json:"target,omitempty"`
|
Target string `json:"target,omitempty"`
|
||||||
|
|
||||||
|
// Context names a second repository this image's build reaches into for its own source — the
|
||||||
|
// recipe itself is still read from this module's own directory, at this module's own commit;
|
||||||
|
// only the build context `docker build`'s final argument names comes from here instead.
|
||||||
|
//
|
||||||
|
// **Packaging and source are allowed to live apart.** A module that only ships the recipe for
|
||||||
|
// source that lives elsewhere — the reference route-proxy in mesh-controller's own repository,
|
||||||
|
// packaged as a module in the catalogue rather than vendored a second time the two copies
|
||||||
|
// could drift from — names where that source actually is. Empty means the ordinary case: an
|
||||||
|
// image built from this same module's own repository, the same as every other artifact.
|
||||||
|
Context *ArtifactContext `json:"context,omitempty"`
|
||||||
|
|
||||||
// Language is what this module's code is written in, for a bundle.
|
// Language is what this module's code is written in, for a bundle.
|
||||||
//
|
//
|
||||||
// **Declared, never guessed.** Inferring it from what files happen to be present makes a
|
// **Declared, never guessed.** Inferring it from what files happen to be present makes a
|
||||||
@@ -615,16 +650,24 @@ func AccessID(path string) string { return "access-" + strings.TrimPrefix(path,
|
|||||||
// it contributes to.
|
// it contributes to.
|
||||||
func (m Manifest) Wants() []string {
|
func (m Manifest) Wants() []string {
|
||||||
out := append([]string{}, m.Requires...)
|
out := append([]string{}, m.Requires...)
|
||||||
for to := range m.Contributes {
|
add := func(to string) {
|
||||||
var already bool
|
|
||||||
for _, r := range m.Requires {
|
for _, r := range m.Requires {
|
||||||
if r == to {
|
if r == to {
|
||||||
already = true
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, already := range out {
|
||||||
|
if already == to {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !already {
|
|
||||||
out = append(out, to)
|
out = append(out, to)
|
||||||
}
|
}
|
||||||
|
for to := range m.Contributes {
|
||||||
|
add(to)
|
||||||
|
}
|
||||||
|
for to := range m.ContributesMany {
|
||||||
|
add(to)
|
||||||
}
|
}
|
||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out
|
return out
|
||||||
@@ -679,6 +722,47 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
|
|||||||
}
|
}
|
||||||
delete(keys, "secrets")
|
delete(keys, "secrets")
|
||||||
}
|
}
|
||||||
|
contributesPlain := map[string]map[string]any{}
|
||||||
|
contributesMany := map[string]map[string]map[string]any{}
|
||||||
|
if contributes, ok := keys["contributes"]; ok && string(contributes) != "null" {
|
||||||
|
var byTo map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(contributes, &byTo); err != nil {
|
||||||
|
return fmt.Errorf("contributes: an object of requirement to values, or to {local name: values}: %w", err)
|
||||||
|
}
|
||||||
|
for to, v := range byTo {
|
||||||
|
// Both shapes are JSON objects, unlike secrets' path-vs-object split, so the shapes are
|
||||||
|
// told apart by what is INSIDE: an ordinary contribution's fields are scalars (a label,
|
||||||
|
// a port); the several-instance shape is an object of local names, each itself an
|
||||||
|
// object of fields. Confirmed against the whole catalogue before relying on it — no
|
||||||
|
// contribution anywhere has an object-valued field.
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(v, &fields); err != nil {
|
||||||
|
return fmt.Errorf("contributes.%s: an object of values, or of local name to values: %w", to, err)
|
||||||
|
}
|
||||||
|
many := len(fields) > 0
|
||||||
|
for _, field := range fields {
|
||||||
|
trimmed := bytes.TrimSpace(field)
|
||||||
|
if len(trimmed) == 0 || trimmed[0] != '{' {
|
||||||
|
many = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if many {
|
||||||
|
var locals map[string]map[string]any
|
||||||
|
if err := json.Unmarshal(v, &locals); err != nil {
|
||||||
|
return fmt.Errorf("contributes.%s: an object of local name to values: %w", to, err)
|
||||||
|
}
|
||||||
|
contributesMany[to] = locals
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var values map[string]any
|
||||||
|
if err := json.Unmarshal(v, &values); err != nil {
|
||||||
|
return fmt.Errorf("contributes.%s: an object of values: %w", to, err)
|
||||||
|
}
|
||||||
|
contributesPlain[to] = values
|
||||||
|
}
|
||||||
|
delete(keys, "contributes")
|
||||||
|
}
|
||||||
rest, err := json.Marshal(keys)
|
rest, err := json.Marshal(keys)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -696,22 +780,46 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
|
|||||||
if len(many) > 0 {
|
if len(many) > 0 {
|
||||||
m.SecretsMany = many
|
m.SecretsMany = many
|
||||||
}
|
}
|
||||||
|
if len(contributesPlain) > 0 {
|
||||||
|
m.Contributes = contributesPlain
|
||||||
|
}
|
||||||
|
if len(contributesMany) > 0 {
|
||||||
|
m.ContributesMany = contributesMany
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON writes `secrets` back in the shape it was read: paths, and objects of local names.
|
// MarshalJSON writes `secrets` and `contributes` back in the shape they were read: single values,
|
||||||
|
// and objects of local names.
|
||||||
func (m Manifest) MarshalJSON() ([]byte, error) {
|
func (m Manifest) MarshalJSON() ([]byte, error) {
|
||||||
raw, err := json.Marshal(manifestFields(m))
|
raw, err := json.Marshal(manifestFields(m))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(m.SecretsMany) == 0 {
|
if len(m.SecretsMany) == 0 && len(m.ContributesMany) == 0 {
|
||||||
return raw, nil
|
return raw, nil
|
||||||
}
|
}
|
||||||
var keys map[string]json.RawMessage
|
var keys map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if len(m.ContributesMany) > 0 {
|
||||||
|
mergedContributes := map[string]any{}
|
||||||
|
for to, values := range m.Contributes {
|
||||||
|
mergedContributes[to] = values
|
||||||
|
}
|
||||||
|
for to, locals := range m.ContributesMany {
|
||||||
|
mergedContributes[to] = locals
|
||||||
|
}
|
||||||
|
contributes, err := json.Marshal(mergedContributes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys["contributes"] = contributes
|
||||||
|
}
|
||||||
|
if len(m.SecretsMany) == 0 {
|
||||||
|
return json.Marshal(keys)
|
||||||
|
}
|
||||||
merged := map[string]any{}
|
merged := map[string]any{}
|
||||||
for to, path := range m.Secrets {
|
for to, path := range m.Secrets {
|
||||||
merged[to] = path
|
merged[to] = path
|
||||||
@@ -842,9 +950,11 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
problems = append(problems, fmt.Sprintf("%s requires itself", m.Module))
|
problems = append(problems, fmt.Sprintf("%s requires itself", m.Module))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
wellFormed := true
|
||||||
for _, c := range m.Claims {
|
for _, c := range m.Claims {
|
||||||
if !name.MatchString(c.Name) {
|
if !name.MatchString(c.Name) {
|
||||||
problems = append(problems, fmt.Sprintf("%q is not a usable claim name", c.Name))
|
problems = append(problems, fmt.Sprintf("%q is not a usable claim name", c.Name))
|
||||||
|
wellFormed = false
|
||||||
}
|
}
|
||||||
switch c.At() {
|
switch c.At() {
|
||||||
case ScopeNode, ScopeSite, ScopeMesh:
|
case ScopeNode, ScopeSite, ScopeMesh:
|
||||||
@@ -852,8 +962,14 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s claims %s at scope %q; a claim is held per node, per site or per mesh",
|
"%s claims %s at scope %q; a claim is held per node, per site or per mesh",
|
||||||
m.Module, c.Name, c.Scope))
|
m.Module, c.Name, c.Scope))
|
||||||
|
wellFormed = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Against the seats the mesh defines (novox/hq ADR 0110), once every claim is at least a name
|
||||||
|
// and a scope — a malformed claim is refused for that, not a second time for being unknown.
|
||||||
|
if wellFormed {
|
||||||
|
problems = append(problems, claimProblems(m)...)
|
||||||
|
}
|
||||||
if m.Computed != "" && len(m.Resources) > 0 {
|
if m.Computed != "" && len(m.Resources) > 0 {
|
||||||
// One or the other. A module that both ships files and has them computed would leave
|
// One or the other. A module that both ships files and has them computed would leave
|
||||||
// nobody able to say where a given file came from.
|
// nobody able to say where a given file came from.
|
||||||
@@ -872,6 +988,25 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"%s contributes nothing to %q; if it only needs one, require it", m.Module, to))
|
"%s contributes nothing to %q; if it only needs one, require it", m.Module, to))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for to, locals := range m.ContributesMany {
|
||||||
|
if !name.MatchString(to) {
|
||||||
|
problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to))
|
||||||
|
}
|
||||||
|
if len(locals) == 0 {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s contributes nothing to %q; if it only needs one, require it", m.Module, to))
|
||||||
|
}
|
||||||
|
for local, values := range locals {
|
||||||
|
if !name.MatchString(local) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s contributes to %q under %q, which is not a usable name", m.Module, to, local))
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s contributes nothing to %q under %q", m.Module, to, local))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
problems = append(problems, m.Build.problems(m.Module)...)
|
problems = append(problems, m.Build.problems(m.Module)...)
|
||||||
// **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029).
|
// **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ type Provider struct {
|
|||||||
At string
|
At string
|
||||||
// Serves is what the providing module said a consumer needs to know, settled.
|
// Serves is what the providing module said a consumer needs to know, settled.
|
||||||
Serves map[string]any
|
Serves map[string]any
|
||||||
|
// Module is which module on that node provides it. A provider is a (node, module) pair
|
||||||
|
// (novox/hq to-be 23), and the pair is what tells the holder of a seat apart from another module
|
||||||
|
// providing the same thing (ADR 0110).
|
||||||
|
Module string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Held is a claim somebody already has, used for the scopes wider than one node.
|
// Held is a claim somebody already has, used for the scopes wider than one node.
|
||||||
@@ -381,6 +385,15 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
default:
|
default:
|
||||||
chosenNode, pinned := world.Pinned[want]
|
chosenNode, pinned := world.Pinned[want]
|
||||||
if !pinned {
|
if !pinned {
|
||||||
|
// **The seat's holder answers, when a seat delivers this** (novox/hq ADR 0110).
|
||||||
|
// Not a guess, which ADR 0009 refuses: the choice was made once, mesh-wide, by
|
||||||
|
// assigning the holder, where a pin makes it again on every consumer's node. A
|
||||||
|
// pin still wins — it is a consumer coupled to one provider's contents, and has
|
||||||
|
// said so.
|
||||||
|
if holder, held := HolderAmong(want, where, world.Held); held {
|
||||||
|
take(holder)
|
||||||
|
break
|
||||||
|
}
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%d nodes provide %q, wanted by %s — say which with `pin %s %s <node>`: %s",
|
"%d nodes provide %q, wanted by %s — say which with `pin %s %s <node>`: %s",
|
||||||
len(where), want, because[want], node.Name, want,
|
len(where), want, because[want], node.Name, want,
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
|||||||
t.Fatalf("the resolver's data is the mesh's addresses, and nothing answering them was taken: %v", named(got))
|
t.Fatalf("the resolver's data is the mesh's addresses, and nothing answering them was taken: %v", named(got))
|
||||||
}
|
}
|
||||||
out, err := got.Declaration(Rendering{
|
out, err := got.Declaration(Rendering{
|
||||||
Names: twoMachines, Suffix: "internal",
|
// Names is every name the mesh serves; Machines is the subset that is a node (novox/hq
|
||||||
|
// issue 111) — the resolver's zones read only the second, and in this scenario the two
|
||||||
|
// happen to be the same map, since nothing routed is part of it.
|
||||||
|
Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
||||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -132,6 +132,72 @@ func TestALabelWithNoPublicDomainComposesNothing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withPrivateAddress is a workstation on the private network, at the given internal name — the
|
||||||
|
// same fact a route's own consumers already receive as `${bound:...:at}`.
|
||||||
|
func withPrivateAddress(at string) Node {
|
||||||
|
n := workstation()
|
||||||
|
n.At = at
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestALabelComposesWithTheNodesPrivateAddressToo(t *testing.T) {
|
||||||
|
// A predecessor proxy answered a route on both a public and a private-network hostname for the
|
||||||
|
// same convenience the mesh restores here: reaching a service over the VPN without a public TLS
|
||||||
|
// round trip. Composed independently of the public name, from the node's own `At`.
|
||||||
|
got, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
|
||||||
|
[]string{"board"}, withPrivateAddress("anchor.internal"), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
given := received(t, mustDeclare(t, got))
|
||||||
|
if given[0].Values["internal-name"] != "git.anchor.internal" {
|
||||||
|
t.Fatalf("the label did not compose with the private address: %v", given[0].Values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThePublicAndInternalNamesComposeIndependently(t *testing.T) {
|
||||||
|
// A node with both a public domain and a private address gets both names from one label; a
|
||||||
|
// node with only one of the two gets only the matching one — neither composition depends on
|
||||||
|
// the other being possible.
|
||||||
|
both := withPrivateAddress("anchor.internal")
|
||||||
|
both.PublicDomain = "example.tld"
|
||||||
|
got, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
|
||||||
|
[]string{"board"}, both, World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
given := received(t, mustDeclare(t, got))
|
||||||
|
if given[0].Values["name"] != "git.example.tld" {
|
||||||
|
t.Fatalf("the public name did not compose alongside the internal one: %v", given[0].Values)
|
||||||
|
}
|
||||||
|
if given[0].Values["internal-name"] != "git.anchor.internal" {
|
||||||
|
t.Fatalf("the internal name did not compose alongside the public one: %v", given[0].Values)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicOnly, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
|
||||||
|
[]string{"board"}, withDomain("example.tld"), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
givenPublicOnly := received(t, mustDeclare(t, publicOnly))
|
||||||
|
if _, has := givenPublicOnly[0].Values["internal-name"]; has {
|
||||||
|
t.Fatalf("an internal name was composed with no private address to compose it from: %v",
|
||||||
|
givenPublicOnly[0].Values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheApexLabelComposesToTheBarePrivateAddress(t *testing.T) {
|
||||||
|
got, err := Resolve(shelf(proxy(), labelled("board", "@", 4000)),
|
||||||
|
[]string{"board"}, withPrivateAddress("anchor.internal"), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
given := received(t, mustDeclare(t, got))
|
||||||
|
if given[0].Values["internal-name"] != "anchor.internal" {
|
||||||
|
t.Fatalf("the apex label did not compose to the bare private address: %v", given[0].Values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestARoutedNameResolvesToTheServingNode(t *testing.T) {
|
func TestARoutedNameResolvesToTheServingNode(t *testing.T) {
|
||||||
// novox/hq ADR 0066 propagate: a granted route name is published into internal resolution,
|
// novox/hq ADR 0066 propagate: a granted route name is published into internal resolution,
|
||||||
// mapped to the node that serves it, alongside the `<node>.internal` names — so every
|
// mapped to the node that serves it, alongside the `<node>.internal` names — so every
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The seats a mesh can have (novox/hq ADR 0110).
|
||||||
|
//
|
||||||
|
// **A closed set, defined here rather than by whoever claims one.** Until this, a well-formed name
|
||||||
|
// became a seat by being claimed, so nothing could say which seats a mesh has or who fills them:
|
||||||
|
// `the-showcase` and `the-build-machine` were each invented by the module claiming it. The set is
|
||||||
|
// what a person reads to learn what a mesh can have, so an entry nobody argued for is an entry
|
||||||
|
// nobody can explain — the same reason every shape in the host's vocabulary names its decision.
|
||||||
|
//
|
||||||
|
// A seat is held by a module assignment. What the mesh knows about a holder is what it knows about
|
||||||
|
// that assignment; nothing about holders is kept here or anywhere else.
|
||||||
|
|
||||||
|
// Seat is one role the mesh defines.
|
||||||
|
type Seat struct {
|
||||||
|
// Name is what a manifest claims.
|
||||||
|
Name string
|
||||||
|
// Scope is where there may be only one holder.
|
||||||
|
Scope string
|
||||||
|
// Delivers is the provision the seat's holder answers for, or empty. A seat that delivers a
|
||||||
|
// provision may only be held by a module providing it at the seat's scope, and its holder is
|
||||||
|
// what a requirement for that provision resolves to when several modules provide it.
|
||||||
|
Delivers string
|
||||||
|
// Decision is the record that made it a seat.
|
||||||
|
Decision string
|
||||||
|
}
|
||||||
|
|
||||||
|
// seats is the whole set, in the order a person reads it: the mesh's own, then a node's.
|
||||||
|
var seats = []Seat{
|
||||||
|
{Name: "mesh-controller", Scope: ScopeMesh, Decision: "novox/hq ADR 0079"},
|
||||||
|
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"},
|
||||||
|
{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "amqp", Decision: "novox/hq ADR 0079"},
|
||||||
|
{Name: "the-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
|
||||||
|
{Name: "the-catalogue", Scope: ScopeMesh, Decision: "novox/hq ADR 0110"},
|
||||||
|
{Name: "npm-package-registry", Scope: ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"},
|
||||||
|
{Name: "git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"},
|
||||||
|
{Name: "the-build-machine", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||||
|
{Name: "the-dns-port", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||||
|
{Name: "the-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||||
|
{Name: "the-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||||
|
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||||
|
{Name: "the-resolver-configuration", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||||
|
{Name: "the-showcase", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seats is every seat the mesh defines, in reading order.
|
||||||
|
func Seats() []Seat {
|
||||||
|
return append([]Seat(nil), seats...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeatNamed is the seat a claim names, if the mesh defines one.
|
||||||
|
func SeatNamed(name string) (Seat, bool) {
|
||||||
|
for _, s := range seats {
|
||||||
|
if s.Name == name {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Seat{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeatDelivering is the seat whose holder answers for a provision, if there is one.
|
||||||
|
func SeatDelivering(provision string) (Seat, bool) {
|
||||||
|
if provision == "" {
|
||||||
|
return Seat{}, false
|
||||||
|
}
|
||||||
|
for _, s := range seats {
|
||||||
|
if s.Delivers == provision {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Seat{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimProblems is what is wrong with a manifest's claims against the set.
|
||||||
|
//
|
||||||
|
// Three refusals, each naming the seat: a seat the mesh does not define, a seat claimed at another
|
||||||
|
// scope, and a seat that delivers a provision claimed by a module that does not provide it — which
|
||||||
|
// would make the module the mesh's answer for something it cannot answer.
|
||||||
|
func claimProblems(m Manifest) []string {
|
||||||
|
var problems []string
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
seat, known := SeatNamed(c.Name)
|
||||||
|
if !known {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s claims %q, which is not a seat this mesh defines (novox/hq ADR 0110) — "+
|
||||||
|
"the seats are: %s", m.Module, c.Name, seatNames()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.At() != seat.Scope {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s claims %s at scope %q, and %s is a %s seat",
|
||||||
|
m.Module, c.Name, c.At(), c.Name, seat.Scope))
|
||||||
|
}
|
||||||
|
if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
|
||||||
|
m.Module, c.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
func providesAt(m Manifest, provision, scope string) bool {
|
||||||
|
for _, o := range m.Provides {
|
||||||
|
if o.Name == provision && o.At() == scope {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func seatNames() string {
|
||||||
|
names := make([]string, 0, len(seats))
|
||||||
|
for _, s := range seats {
|
||||||
|
names = append(names, s.Name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HolderAmong is which of several providers of a provision holds the seat that delivers it.
|
||||||
|
//
|
||||||
|
// Found by the (node, module) pair, because a provider is identified by both (novox/hq to-be 23):
|
||||||
|
// two modules on one node could both provide a provision, and only the one holding the seat
|
||||||
|
// answers for it. Nothing when no seat delivers the provision, when nobody holds
|
||||||
|
// it, or when the holder is not among the providers offered.
|
||||||
|
func HolderAmong(provision string, providers []Provider, held []Held) (Provider, bool) {
|
||||||
|
seat, delivered := SeatDelivering(provision)
|
||||||
|
if !delivered {
|
||||||
|
return Provider{}, false
|
||||||
|
}
|
||||||
|
for _, h := range held {
|
||||||
|
if h.Claim != seat.Name || h.Scope != seat.Scope {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range providers {
|
||||||
|
if p.Node == h.Node && p.Module == h.Module {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Provider{}, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0110: a seat is a module assignment from a closed set.
|
||||||
|
|
||||||
|
// The set is closed, and changing it is a decision.
|
||||||
|
//
|
||||||
|
// **The count is asserted, and every entry names the record that made it a seat**, so the next
|
||||||
|
// person changing the set finds the argument rather than a number to edit — the pattern the host's
|
||||||
|
// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq
|
||||||
|
// and a row in to-be 26, not a new number here.
|
||||||
|
func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||||
|
record := regexp.MustCompile(`^novox/hq ADR \d{4}$`)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
delivered := map[string]string{}
|
||||||
|
for _, s := range Seats() {
|
||||||
|
if seen[s.Name] {
|
||||||
|
t.Errorf("%s is in the set twice", s.Name)
|
||||||
|
}
|
||||||
|
seen[s.Name] = true
|
||||||
|
if !record.MatchString(s.Decision) {
|
||||||
|
t.Errorf("%s names %q as its decision; every seat names the record that made it one",
|
||||||
|
s.Name, s.Decision)
|
||||||
|
}
|
||||||
|
switch s.Scope {
|
||||||
|
case ScopeNode, ScopeSite, ScopeMesh:
|
||||||
|
default:
|
||||||
|
t.Errorf("%s is held per %q, which is not a scope", s.Name, s.Scope)
|
||||||
|
}
|
||||||
|
if s.Delivers != "" {
|
||||||
|
// Two seats answering for one provision would put the question "which one?" back,
|
||||||
|
// which is the question a seat exists to answer.
|
||||||
|
if other, twice := delivered[s.Delivers]; twice {
|
||||||
|
t.Errorf("%s and %s both deliver %q", other, s.Name, s.Delivers)
|
||||||
|
}
|
||||||
|
delivered[s.Delivers] = s.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(Seats()) != 14 {
|
||||||
|
t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+
|
||||||
|
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimed(claims string) []byte {
|
||||||
|
return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh"}],"claims":` + claims + `}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAClaimOnASeatTheMeshDoesNotDefineIsRefused(t *testing.T) {
|
||||||
|
_, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a module invented a seat by claiming it")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "the-anything") || !strings.Contains(err.Error(), "not a seat") {
|
||||||
|
t.Fatalf("the refusal does not say the seat is unknown: %v", err)
|
||||||
|
}
|
||||||
|
// And it says what the seats are, because "no" without the list sends somebody reading code.
|
||||||
|
if !strings.Contains(err.Error(), "the-packet-filter") {
|
||||||
|
t.Fatalf("the refusal does not list the seats: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASeatClaimedAtAnotherScopeIsRefused(t *testing.T) {
|
||||||
|
_, err := ParseManifest(claimed(`[{"name":"npm-package-registry","scope":"node"}]`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a mesh seat was held per node")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "mesh seat") {
|
||||||
|
t.Fatalf("the refusal does not say which scope the seat is: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADeliveringSeatIsOnlyHeldByAModuleThatProvides(t *testing.T) {
|
||||||
|
// Holding it makes the module the mesh's answer for the provision. A module that cannot answer
|
||||||
|
// would be the answer anyway, and every consumer would be sent to it.
|
||||||
|
raw := []byte(`{"module":"thing","version":"1","claims":[{"name":"git","scope":"mesh"}]}`)
|
||||||
|
_, err := ParseManifest(raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a module holding the git seat need not provide git")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `does not provide "git"`) {
|
||||||
|
t.Fatalf("the refusal does not say what is missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAClaimThatIsMalformedIsRefusedOnceForThat(t *testing.T) {
|
||||||
|
// Not a second time for being unknown: one mistake, one line.
|
||||||
|
_, err := ParseManifest(claimed(`[{"name":"Not A Name","scope":"node"}]`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a malformed claim was accepted")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "not a seat") {
|
||||||
|
t.Fatalf("a malformed claim was also called unknown: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every module in use claims a seat in the set, so closing it refuses nothing that runs.
|
||||||
|
//
|
||||||
|
// Read from the catalogue beside this checkout and from this repository's own manifest, the two
|
||||||
|
// places a manifest lives (ADR 0069). The private-network module's manifest is composed in code,
|
||||||
|
// and its claim is checked where it is composed.
|
||||||
|
func TestEveryManifestInUseClaimsASeatTheMeshDefines(t *testing.T) {
|
||||||
|
paths, _ := filepath.Glob("../../../mesh-catalog/modules/*/module.json")
|
||||||
|
if len(paths) == 0 {
|
||||||
|
t.Skip("the catalogue is not beside this checkout")
|
||||||
|
}
|
||||||
|
paths = append(paths, "../../module.json")
|
||||||
|
var checked int
|
||||||
|
for _, path := range paths {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Leniently, so a manifest refused for something unrelated is not reported as a seat
|
||||||
|
// problem, and the seat check below is the only thing this test holds a module to.
|
||||||
|
var m Manifest
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("%s: %v", path, err)
|
||||||
|
}
|
||||||
|
for _, problem := range claimProblems(m) {
|
||||||
|
t.Errorf("%s: %s", path, problem)
|
||||||
|
}
|
||||||
|
checked += len(m.Claims)
|
||||||
|
}
|
||||||
|
if checked == 0 {
|
||||||
|
t.Fatal("no claims were checked, so this proved nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The holder of a seat answers among several providers.
|
||||||
|
|
||||||
|
func registryShelf() map[string]Manifest {
|
||||||
|
return shelf(
|
||||||
|
Manifest{Module: "gitea", Version: "1", Provides: FromAnywhere("npm-package-registry"),
|
||||||
|
Claims: []Claim{{Name: "npm-package-registry", Scope: ScopeMesh}}},
|
||||||
|
Manifest{Module: "verdaccio", Version: "1", Provides: FromAnywhere("npm-package-registry")},
|
||||||
|
Manifest{Module: "builder", Version: "1", Requires: []string{"npm-package-registry"}},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func twoRegistries() map[string][]Provider {
|
||||||
|
return map[string][]Provider{"npm-package-registry": {
|
||||||
|
{Node: "anchor", At: "anchor.internal", Module: "gitea"},
|
||||||
|
{Node: "archive", At: "archive.internal", Module: "verdaccio"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func giteaHoldsTheSeat() []Held {
|
||||||
|
return []Held{{Claim: "npm-package-registry", Scope: ScopeMesh, Node: "anchor", Module: "gitea"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSeatsHolderAnswersWhenSeveralProvide(t *testing.T) {
|
||||||
|
// The whole point: a second registry beside the holder harms nothing, and nobody pins.
|
||||||
|
got, err := Resolve(registryShelf(), []string{"builder"}, reachable(),
|
||||||
|
World{Offered: twoRegistries(), Held: giteaHoldsTheSeat()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Needs) != 1 || got.Needs[0].From != "anchor" {
|
||||||
|
t.Fatalf("the seat's holder did not answer: %v", got.Needs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPinStillWinsOverTheSeat(t *testing.T) {
|
||||||
|
// A consumer coupled to one provider's contents has said so, and the seat does not overrule it.
|
||||||
|
got, err := Resolve(registryShelf(), []string{"builder"}, reachable(),
|
||||||
|
World{Offered: twoRegistries(), Held: giteaHoldsTheSeat(),
|
||||||
|
Pinned: map[string]string{"npm-package-registry": "archive"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Needs) != 1 || got.Needs[0].From != "archive" {
|
||||||
|
t.Fatalf("the pin was overruled by the seat: %v", got.Needs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTheSeatUnheldSeveralProvidersAreStillRefused(t *testing.T) {
|
||||||
|
// No seat held is no choice made, and ADR 0009's rule stands: never guessed.
|
||||||
|
_, err := Resolve(registryShelf(), []string{"builder"}, reachable(),
|
||||||
|
World{Offered: twoRegistries()})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("one of two registries was picked with nobody holding the seat")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "pin") {
|
||||||
|
t.Fatalf("the refusal does not say how to choose: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHolderIsTheModuleNotTheMachine(t *testing.T) {
|
||||||
|
// Two modules on one machine could provide the same thing; only the one holding the seat
|
||||||
|
// answers. A holder matched by node alone would send consumers to whichever came first.
|
||||||
|
providers := []Provider{
|
||||||
|
{Node: "anchor", At: "anchor.internal", Module: "verdaccio"},
|
||||||
|
{Node: "anchor", At: "anchor.internal", Module: "gitea"},
|
||||||
|
}
|
||||||
|
holder, held := HolderAmong("npm-package-registry", providers, giteaHoldsTheSeat())
|
||||||
|
if !held || holder.Module != "gitea" {
|
||||||
|
t.Fatalf("the holder was not told apart from a neighbour: %+v", holder)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module may answer one requirement more than once, the sibling of ADR 0094 for `contributes`
|
||||||
|
// rather than `secrets`: an object store's data API and its console are two different public
|
||||||
|
// names, not one. `contributes` maps a requirement to several sets of values under local names,
|
||||||
|
// each reaching the provider as its own entry — the same "several from one" shape ADR 0094 gave
|
||||||
|
// `secrets`, applied to the other half of an edge.
|
||||||
|
|
||||||
|
const twoRoutes = `{"module":"minio","version":"1","requires":["route"],
|
||||||
|
"contributes":{"route":{"api":{"label":"files-api","port":9000},"console":{"label":"files","port":9001}}}}`
|
||||||
|
|
||||||
|
func TestContributesReadsBothShapesAndWritesThemBack(t *testing.T) {
|
||||||
|
m, err := ParseManifest([]byte(twoRoutes))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
locals := m.ContributesMany["route"]
|
||||||
|
if len(locals) != 2 || locals["api"]["label"] != "files-api" || locals["console"]["port"] != float64(9001) {
|
||||||
|
t.Fatalf("two contributions under local names: %+v", locals)
|
||||||
|
}
|
||||||
|
plain, err := ParseManifest([]byte(`{"module":"board","version":"1","requires":["route"],
|
||||||
|
"contributes":{"route":{"label":"board","port":8080}}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := plain.Contributes["route"]; got["label"] != "board" || len(plain.ContributesMany) != 0 {
|
||||||
|
t.Fatalf("the plain shape is one contribution with no local names: %+v / %+v", got, plain.ContributesMany)
|
||||||
|
}
|
||||||
|
// Written back in the shape it was read, so a built manifest keeps its local names.
|
||||||
|
raw, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("what was written does not read: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if len(again.ContributesMany["route"]) != 2 {
|
||||||
|
t.Fatalf("the local names did not survive a round trip:\n%s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAContributionLocalNameMustBeUsable(t *testing.T) {
|
||||||
|
for _, bad := range []string{
|
||||||
|
// Not a usable name.
|
||||||
|
`{"module":"minio","version":"1","requires":["route"],
|
||||||
|
"contributes":{"route":{"Not OK":{"label":"files","port":9000}}}}`,
|
||||||
|
// A local contribution with nothing in it.
|
||||||
|
`{"module":"minio","version":"1","requires":["route"],
|
||||||
|
"contributes":{"route":{"api":{}}}}`,
|
||||||
|
} {
|
||||||
|
if _, err := ParseManifest([]byte(bad)); err == nil {
|
||||||
|
t.Errorf("accepted:\n%s", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func minimalRouteProxy() Manifest {
|
||||||
|
return Manifest{Module: "route-proxy", Version: "1",
|
||||||
|
Provides: FromAnywhere("route"),
|
||||||
|
Receives: map[string]string{"route": "/var/lib/route-proxy/routes/mesh.json"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAModuleWithTwoRoutesGivesTheProviderTwoContributions(t *testing.T) {
|
||||||
|
minio, err := ParseManifest([]byte(twoRoutes))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := Resolve(shelf(minimalRouteProxy(), minio), []string{"route-proxy", "minio"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := got.Declaration(Rendering{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var given []Contribution
|
||||||
|
for _, r := range out {
|
||||||
|
if r["path"] != "/var/lib/route-proxy/routes/mesh.json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Given []Contribution `json:"given"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
given = parsed.Given
|
||||||
|
}
|
||||||
|
if len(given) != 2 {
|
||||||
|
t.Fatalf("two named routes from one module are two contributions: %+v", given)
|
||||||
|
}
|
||||||
|
byPort := map[float64]string{}
|
||||||
|
for _, g := range given {
|
||||||
|
if g.From != "minio" {
|
||||||
|
t.Fatalf("both contributions are minio's: %+v", g)
|
||||||
|
}
|
||||||
|
port, _ := g.Values["port"].(float64)
|
||||||
|
label, _ := g.Values["label"].(string)
|
||||||
|
byPort[port] = label
|
||||||
|
}
|
||||||
|
if byPort[9000] != "files-api" || byPort[9001] != "files" {
|
||||||
|
t.Fatalf("the two routes did not both survive: %+v", given)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module with the ordinary, single-contribution shape resolves exactly as it did before —
|
||||||
|
// ContributesMany being empty must change nothing about it.
|
||||||
|
func TestASingleRouteStillResolvesTheOrdinaryWay(t *testing.T) {
|
||||||
|
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
given := received(t, mustDeclare(t, got))
|
||||||
|
if len(given) != 1 || given[0].From != "board" {
|
||||||
|
t.Fatalf("the plain shape regressed: %+v", given)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContributionsFrom is what mints the ONE pair credential a requiring module is granted
|
||||||
|
// (cmd/mesh-controller/plan.go's grantsFor) — a separate path from Declaration()'s raw file, and
|
||||||
|
// the one the two-routes test above never exercised. Where a module contributes several times,
|
||||||
|
// there is no single "the" value: settling to whichever sorts first would both misrepresent the
|
||||||
|
// grant and collide with that same contribution's own entry from contributions(), which is
|
||||||
|
// exactly the duplicate a live plan against minio surfaced (files-api appearing once with a
|
||||||
|
// credential, once without, while files got neither).
|
||||||
|
func TestContributionsFromHasNoSingleValueWhenAModuleContributesSeveralTimes(t *testing.T) {
|
||||||
|
minio, err := ParseManifest([]byte(twoRoutes))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := Resolve(shelf(minimalRouteProxy(), minio), []string{"route-proxy", "minio"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
values, asks, err := got.ContributionsFrom("route", "minio", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !asks {
|
||||||
|
t.Fatal("minio still requires route, so it still asks")
|
||||||
|
}
|
||||||
|
if len(values) != 0 {
|
||||||
|
t.Fatalf("no single value represents two contributions, got %+v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ordinary, single-contribution case is unchanged: exactly one match still settles to it.
|
||||||
|
func TestContributionsFromReturnsTheOneValueForAnOrdinaryContribution(t *testing.T) {
|
||||||
|
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
values, asks, err := got.ContributionsFrom("reverse-proxy", "board", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !asks || values["host"] != "board" {
|
||||||
|
t.Fatalf("the ordinary single contribution should still settle to its own value: %+v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,12 @@ var ErrStillAssigned = errors.New("that module is still assigned to nodes")
|
|||||||
|
|
||||||
// Source is where a module comes from and what has been built from it.
|
// Source is where a module comes from and what has been built from it.
|
||||||
type Source struct {
|
type Source struct {
|
||||||
|
// Repository is a URL, cloned exactly as given, unless Seat is set — then it is the
|
||||||
|
// repository's path on that seat's holder, and never an address (novox/hq ADR 0111).
|
||||||
Repository string
|
Repository string
|
||||||
|
// Seat is the seat the repository lives on: `git` for the mesh's own forge, empty for a
|
||||||
|
// repository anywhere else.
|
||||||
|
Seat string
|
||||||
// Path is the module's directory inside that repository (novox/hq ADR 0069). Empty is the
|
// Path is the module's directory inside that repository (novox/hq ADR 0069). Empty is the
|
||||||
// repository's root, which is a real answer rather than a missing one.
|
// repository's root, which is a real answer rather than a missing one.
|
||||||
Path string
|
Path string
|
||||||
@@ -62,8 +67,8 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
|||||||
// record of where the module normally comes from — which is the only thing that would say,
|
// record of where the module normally comes from — which is the only thing that would say,
|
||||||
// afterwards, that the machine is running something nobody can rebuild.
|
// afterwards, that the machine is running something nobody can rebuild.
|
||||||
_, err = i.store.Pool().Exec(ctx,
|
_, err = i.store.Pool().Exec(ctx,
|
||||||
`insert into module (name, manifest, version, source, source_path, ref, built_from, source_head)
|
`insert into module (name, manifest, version, source, source_path, source_seat, ref, built_from, source_head)
|
||||||
values ($1, $2, nullif($3,''), nullif($4,''), $7, nullif($5,''), nullif($6,''), nullif($6,''))
|
values ($1, $2, nullif($3,''), nullif($4,''), $7, $8, nullif($5,''), nullif($6,''), nullif($6,''))
|
||||||
on conflict (name) do update set
|
on conflict (name) do update set
|
||||||
manifest = excluded.manifest,
|
manifest = excluded.manifest,
|
||||||
version = excluded.version,
|
version = excluded.version,
|
||||||
@@ -71,10 +76,12 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
|||||||
source = coalesce(excluded.source, module.source),
|
source = coalesce(excluded.source, module.source),
|
||||||
source_path = case when excluded.source is null then module.source_path
|
source_path = case when excluded.source is null then module.source_path
|
||||||
else excluded.source_path end,
|
else excluded.source_path end,
|
||||||
|
source_seat = case when excluded.source is null then module.source_seat
|
||||||
|
else excluded.source_seat end,
|
||||||
ref = coalesce(excluded.ref, module.ref),
|
ref = coalesce(excluded.ref, module.ref),
|
||||||
built_from = coalesce(excluded.built_from, module.built_from),
|
built_from = coalesce(excluded.built_from, module.built_from),
|
||||||
source_head = coalesce(excluded.built_from, module.source_head)`,
|
source_head = coalesce(excluded.built_from, module.source_head)`,
|
||||||
m.Module, raw, m.Version, from.Repository, from.Ref, from.BuiltFrom, from.Path)
|
m.Module, raw, m.Version, from.Repository, from.Ref, from.BuiltFrom, from.Path, from.Seat)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +106,10 @@ func (i *Inventory) SourceMoved(ctx context.Context, module, head string) error
|
|||||||
func (i *Inventory) SourceOf(ctx context.Context, module string) (Source, error) {
|
func (i *Inventory) SourceOf(ctx context.Context, module string) (Source, error) {
|
||||||
var s Source
|
var s Source
|
||||||
var repo, ref, built, head *string
|
var repo, ref, built, head *string
|
||||||
var path string
|
var path, seat string
|
||||||
err := i.store.Pool().QueryRow(ctx,
|
err := i.store.Pool().QueryRow(ctx,
|
||||||
`select source, source_path, ref, built_from, source_head from module where name = $1`,
|
`select source, source_path, source_seat, ref, built_from, source_head from module where name = $1`,
|
||||||
module).Scan(&repo, &path, &ref, &built, &head)
|
module).Scan(&repo, &path, &seat, &ref, &built, &head)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Source{}, fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
return Source{}, fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
||||||
}
|
}
|
||||||
@@ -117,9 +124,10 @@ func (i *Inventory) SourceOf(ctx context.Context, module string) (Source, error)
|
|||||||
*pair.to = *pair.from
|
*pair.to = *pair.from
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Not in the loop above: the path is never null, because "the repository's root" is an answer
|
// Not in the loop above: the path and the seat are never null, because "the repository's root"
|
||||||
// rather than an absence.
|
// and "not on a seat" are answers rather than absences.
|
||||||
s.Path = path
|
s.Path = path
|
||||||
|
s.Seat = seat
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,13 +925,14 @@ type Entry struct {
|
|||||||
func (i *Inventory) Catalogued(ctx context.Context) ([]Entry, error) {
|
func (i *Inventory) Catalogued(ctx context.Context) ([]Entry, error) {
|
||||||
rows, err := i.store.Pool().Query(ctx,
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
`select m.name, m.manifest,
|
`select m.name, m.manifest,
|
||||||
coalesce(m.source, ''), m.source_path, coalesce(m.ref, ''),
|
coalesce(m.source, ''), m.source_path, m.source_seat, coalesce(m.ref, ''),
|
||||||
coalesce(m.built_from, ''), coalesce(m.source_head, ''),
|
coalesce(m.built_from, ''), coalesce(m.source_head, ''),
|
||||||
coalesce(array_agg(n.name order by n.name) filter (where n.name is not null), '{}')
|
coalesce(array_agg(n.name order by n.name) filter (where n.name is not null), '{}')
|
||||||
from module m
|
from module m
|
||||||
left join assignment a on a.module = m.name
|
left join assignment a on a.module = m.name
|
||||||
left join node n on n.id = a.node
|
left join node n on n.id = a.node
|
||||||
group by m.name, m.manifest, m.source, m.source_path, m.ref, m.built_from, m.source_head
|
group by m.name, m.manifest, m.source, m.source_path, m.source_seat, m.ref, m.built_from,
|
||||||
|
m.source_head
|
||||||
order by m.name`)
|
order by m.name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -936,7 +945,7 @@ func (i *Inventory) Catalogued(ctx context.Context) ([]Entry, error) {
|
|||||||
var name string
|
var name string
|
||||||
var source Source
|
var source Source
|
||||||
var on []string
|
var on []string
|
||||||
if err := rows.Scan(&name, &raw, &source.Repository, &source.Path, &source.Ref,
|
if err := rows.Scan(&name, &raw, &source.Repository, &source.Path, &source.Seat, &source.Ref,
|
||||||
&source.BuiltFrom, &source.Head, &on); err != nil {
|
&source.BuiltFrom, &source.Head, &on); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -604,3 +604,74 @@ func TestNeverReportedAndReportedNothingAreDifferentProfiles(t *testing.T) {
|
|||||||
t.Fatal("a machine that reported nothing looks like one that never reported")
|
t.Fatal("a machine that reported nothing looks like one that never reported")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0111: a source on a seat is recorded as a path and the seat, never an
|
||||||
|
// address, and a module recorded before the column existed keeps meaning a URL.
|
||||||
|
func TestASourceOnASeatIsRecordedAsItsPathAndTheSeat(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
if err := inv.RegisterModule(ctx, manifest("gitea-built", nil, nil), Source{
|
||||||
|
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/gitea", Ref: "main",
|
||||||
|
BuiltFrom: "aaaa1111",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, manifest("external", nil, nil), Source{
|
||||||
|
Repository: "https://example.invalid/someone/something.git", BuiltFrom: "bbbb2222",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
own, err := inv.SourceOf(ctx, "gitea-built")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if own.Seat != "git" || own.Repository != "novox/mesh-catalog" || own.Path != "modules/gitea" {
|
||||||
|
t.Fatalf("recorded as %+v", own)
|
||||||
|
}
|
||||||
|
if strings.Contains(own.Repository, "://") {
|
||||||
|
t.Fatalf("an address was recorded for a source on a seat: %s", own.Repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
elsewhere, err := inv.SourceOf(ctx, "external")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if elsewhere.Seat != "" {
|
||||||
|
t.Fatalf("an external repository was put on a seat: %+v", elsewhere)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the list every rebuild walks carries the seat, or `build --behind` would clone the path
|
||||||
|
// as though it were a URL.
|
||||||
|
all, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range all {
|
||||||
|
if e.Manifest.Module == "gitea-built" && e.Source.Seat != "git" {
|
||||||
|
t.Fatalf("the rebuild list lost the seat: %+v", e.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteringWithoutProvenanceKeepsTheSeat(t *testing.T) {
|
||||||
|
// A manifest handed over by hand keeps the record of where the module normally comes from —
|
||||||
|
// the seat included, or the next rebuild would treat a path as a URL.
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
if err := inv.RegisterModule(ctx, manifest("thing", nil, nil), Source{
|
||||||
|
Repository: "novox/thing", Seat: "git", BuiltFrom: "aaaa1111",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, manifest("thing", []string{"a-thing"}, nil), Source{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := inv.SourceOf(ctx, "thing")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Seat != "git" || got.Repository != "novox/thing" {
|
||||||
|
t.Fatalf("a hand-registered manifest erased where the module comes from: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Which seat a module's source lives on, when it lives on one.
|
||||||
|
--
|
||||||
|
-- novox/hq ADR 0111. A module's repository was recorded exactly as a person typed it, so a
|
||||||
|
-- self-hosted forge's scheme, host and port were written into every module built from it — and
|
||||||
|
-- moving the forge made every one of those records stale at once, noticed only when a rebuild
|
||||||
|
-- failed to clone. A source on the `git` seat is now recorded as its path on the seat's holder, and
|
||||||
|
-- the clone URL is composed from wherever the holder runs at the moment of building.
|
||||||
|
--
|
||||||
|
-- Empty rather than null, and defaulted, because "not on a seat" is a real answer: the repository
|
||||||
|
-- column is then a URL, cloned exactly as given, which is what every module recorded before this
|
||||||
|
-- already is. So every existing row keeps exactly the meaning it had.
|
||||||
|
alter table module add column source_seat text not null default '';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package overlay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The private network's claim is a seat the mesh defines (novox/hq ADR 0110).
|
||||||
|
//
|
||||||
|
// Checked here because this is where the manifest is composed: it ships with the control plane and
|
||||||
|
// has no module.json for a test reading the catalogue to find. Parsed with the real parser, so a
|
||||||
|
// set that forgot this seat refuses the control plane's own module here rather than on a machine.
|
||||||
|
func TestThePrivateNetworksClaimIsASeatTheMeshDefines(t *testing.T) {
|
||||||
|
for _, composed := range []map[string]any{Manifest(), DomainManifest()} {
|
||||||
|
raw, err := json.Marshal(composed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := catalogue.ParseManifest(raw); err != nil {
|
||||||
|
t.Errorf("%s, composed by the control plane, is refused: %v", composed["module"], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seat, defined := catalogue.SeatNamed(TheNetwork)
|
||||||
|
if !defined || seat.Scope != catalogue.ScopeNode {
|
||||||
|
t.Fatalf("%s is not a node seat the mesh defines: %+v", TheNetwork, seat)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user