Unify trunk on main: initialization → main #3
@@ -369,6 +369,27 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outc
|
||||
}
|
||||
content = string(opened)
|
||||
}
|
||||
// Sealed values into the holes the content left for them. **The host is the only thing that
|
||||
// ever holds both** — the mesh discarded the value, and the module wrote the document without
|
||||
// it (novox/hq ADR 0024).
|
||||
if len(r.Secrets) > 0 {
|
||||
if unseal == nil {
|
||||
return out, fmt.Errorf(
|
||||
"%s needs %d sealed value(s) and this node has no sealing key",
|
||||
r.Path, len(r.Secrets))
|
||||
}
|
||||
for _, name := range r.SecretsUsed() {
|
||||
opened, err := unseal(r.Secrets[name])
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("cannot open the secret %q for %s: %w", name, r.Path, err)
|
||||
}
|
||||
content = strings.ReplaceAll(content, "${secret:"+name+"}", string(opened))
|
||||
}
|
||||
// A file carrying a credential is not world-readable, whatever else it also carries.
|
||||
if r.Mode == "" {
|
||||
fallback = 0o600
|
||||
}
|
||||
}
|
||||
out.wrote = digestOf(content)
|
||||
mode, err := modeOf(r.Mode, fallback)
|
||||
if err != nil {
|
||||
@@ -887,6 +908,9 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner) (
|
||||
}
|
||||
|
||||
args := []string{"run", "--detach", "--name", r.Name, "--restart", "unless-stopped"}
|
||||
for _, file := range r.EnvFile {
|
||||
args = append(args, "--env-file", file)
|
||||
}
|
||||
if r.Network != "" {
|
||||
args = append(args, "--network", r.Network)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -317,3 +318,88 @@ func TestANetworkAlreadyThereIsNotRebuilt(t *testing.T) {
|
||||
"name and not the thing, so it does not tear one down and rebuild it", created)
|
||||
}
|
||||
}
|
||||
|
||||
// A secret inside a configuration file, substituted on the machine.
|
||||
//
|
||||
// **The one place a credential and a configuration meet.** A program wanting its token inside a
|
||||
// JSON document cannot be handed a file that is entirely a token, and the mesh cannot compose the
|
||||
// document because it discarded the value. So the module supplies the document with a hole, the
|
||||
// mesh delivers the value sealed, and the host is the only thing that ever holds both.
|
||||
func TestASealedValueIsPutIntoTheFileThatNamesIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
d := declare(t, `{"id":"settings","type":"file","path":"`+path+`",`+
|
||||
`"content":"{\"tracking\":\"on\",\"token\":\"${secret:atlassian}\"}",`+
|
||||
`"secrets":{"atlassian":"SEALED"}}`)
|
||||
|
||||
open := func(blob string) ([]byte, error) {
|
||||
if blob != "SEALED" {
|
||||
return nil, fmt.Errorf("asked to open %q", blob)
|
||||
}
|
||||
return []byte("the-real-token"), nil
|
||||
}
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginDeclared, nil, nil, open); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
written, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(written), `"token":"the-real-token"`) {
|
||||
t.Fatalf("the secret was not put in: %s", written)
|
||||
}
|
||||
if strings.Contains(string(written), "secret:") {
|
||||
t.Fatalf("a placeholder survived into the file: %s", written)
|
||||
}
|
||||
// The rest of the document is untouched — this is substitution, not replacement.
|
||||
if !strings.Contains(string(written), `"tracking":"on"`) {
|
||||
t.Fatalf("the content around the secret was lost: %s", written)
|
||||
}
|
||||
// And it carries a credential, so it is not world-readable.
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("a file holding a credential is %v", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// Defends the reason env-file exists: a credential may not travel in `env`.
|
||||
//
|
||||
// A declaration reaches a node over the broker and `env` is plain text in it, so a password there
|
||||
// is a password the broker sees. A sealed file arrives unreadable, the host writes it, and the
|
||||
// runtime reads it.
|
||||
func TestAContainerIsGivenItsEnvironmentFiles(t *testing.T) {
|
||||
var ran []string
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
ran = append(ran, name+" "+strings.Join(args, " "))
|
||||
if len(args) > 0 && args[0] == "inspect" {
|
||||
return "", fmt.Errorf("no such container")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := declare(t, `{"id":"app","type":"container","name":"umami",`+
|
||||
`"image":"umami@sha256:0000000000000000000000000000000000000000000000000000000000000000",`+
|
||||
`"env-file":["/var/lib/umami/database.env","/var/lib/umami/app.env"]}`)
|
||||
|
||||
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginDeclared, run, nil, nil)
|
||||
|
||||
var started string
|
||||
for _, line := range ran {
|
||||
if strings.Contains(line, "run ") {
|
||||
started = line
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"--env-file /var/lib/umami/database.env",
|
||||
"--env-file /var/lib/umami/app.env",
|
||||
} {
|
||||
if !strings.Contains(started, want) {
|
||||
t.Errorf("the container was started without %q:\n%s", want, started)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -118,6 +120,23 @@ type File struct {
|
||||
// by looking rather than by knowing which field won.
|
||||
Sealed string `json:"sealed,omitempty"`
|
||||
|
||||
// Secrets are sealed values put into Content where it says `${secret:name}`.
|
||||
//
|
||||
// **The one place a secret and a configuration meet, and it happens on the machine.** A
|
||||
// program that wants its token inside a JSON document cannot be given a file that is entirely
|
||||
// a token, and the mesh cannot compose the document itself — it discarded the value
|
||||
// (novox/hq ADR 0024). So the module supplies the document with a hole in it, the mesh
|
||||
// delivers the value sealed, and the host is the only thing that ever sees both.
|
||||
//
|
||||
// **Substitution is textual and the host learns no formats.** That is deliberate: a mechanism
|
||||
// that understood JSON would be asked to understand YAML next, and then INI, which is how the
|
||||
// arrangement this replaces became something nobody could hold in their head. The module knows
|
||||
// its own format, because it wrote the rest of the file.
|
||||
//
|
||||
// The sharp edge, stated rather than discovered: a value containing a quote or a backslash
|
||||
// will not be escaped for whatever syntax surrounds it.
|
||||
Secrets map[string]string `json:"secrets,omitempty"`
|
||||
|
||||
// Bytes is content that is not text, base64-encoded — a wallpaper, a font, an icon.
|
||||
//
|
||||
// A third way of saying what is in a file, and the three are exclusive. It would have been
|
||||
@@ -137,6 +156,22 @@ func (f *File) Identity() string { return f.ID }
|
||||
func (f *File) Kind() Type { return TypeFile }
|
||||
func (f *File) Target() string { return f.Path }
|
||||
|
||||
// placeholder is what Content says where a sealed value belongs: ${secret:name}.
|
||||
var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`)
|
||||
|
||||
// SecretsUsed are the names Content asks for, in the order they first appear.
|
||||
func (f *File) SecretsUsed() []string {
|
||||
var used []string
|
||||
seen := map[string]bool{}
|
||||
for _, m := range placeholder.FindAllStringSubmatch(f.Content, -1) {
|
||||
if !seen[m[1]] {
|
||||
seen[m[1]] = true
|
||||
used = append(used, m[1])
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
func (f *File) validate(where string, _ bool) []string {
|
||||
var problems []string
|
||||
if f.Path == "" {
|
||||
@@ -157,6 +192,29 @@ func (f *File) validate(where string, _ bool) []string {
|
||||
strings.Join(said, " and ")+
|
||||
" — otherwise nobody can tell by looking which one landed on the machine")
|
||||
}
|
||||
// A file whose content names a secret must be given exactly the secrets it names.
|
||||
//
|
||||
// **Both directions, and both are refusals rather than warnings.** A placeholder with nothing
|
||||
// to fill it would write `${secret:x}` into a configuration file, which the program reads as
|
||||
// a value and fails on somewhere unrelated. A secret nobody uses means whoever wrote this
|
||||
// believes a credential is in a file where it is not.
|
||||
if len(f.Secrets) > 0 && f.Content == "" {
|
||||
problems = append(problems, where+
|
||||
": secrets were given and there is no content to put them in")
|
||||
}
|
||||
used := f.SecretsUsed()
|
||||
for _, name := range used {
|
||||
if f.Secrets[name] == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: the content asks for the secret %q and none was given", where, name))
|
||||
}
|
||||
}
|
||||
for name := range f.Secrets {
|
||||
if !slices.Contains(used, name) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: the secret %q was given and the content never asks for it", where, name))
|
||||
}
|
||||
}
|
||||
return append(problems, checkMode(where, f.Mode)...)
|
||||
}
|
||||
|
||||
@@ -364,11 +422,22 @@ type Container struct {
|
||||
Type Type `json:"type"`
|
||||
Name string `json:"name"`
|
||||
// Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not.
|
||||
Image string `json:"image"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
Volumes []string `json:"volumes,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Image string `json:"image"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
// EnvFile names files the runtime reads environment from, in order.
|
||||
//
|
||||
// **Because a secret may not travel in Env.** A declaration reaches a node over the broker,
|
||||
// and `env` is plain text in it — so a password there is a password the broker sees, which is
|
||||
// the transitive trust refused everywhere else (novox/hq ADR 0004). A sealed file reaches the
|
||||
// machine unreadable, the host writes it, and the runtime reads it: the mesh never holds it
|
||||
// and neither does anything between them.
|
||||
//
|
||||
// It is also simply how third-party software takes credentials. Nothing that ships in a
|
||||
// container will read a path the mesh invented; every one of them reads its environment.
|
||||
EnvFile []string `json:"env-file,omitempty"`
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
Volumes []string `json:"volumes,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
// Names this container can reach, as `name:address`.
|
||||
//
|
||||
// **Because a container does not inherit the machine's names.** It gets its own `/etc/hosts`
|
||||
|
||||
@@ -359,3 +359,26 @@ func TestANetworkNameIsRefusedIfItIsNotOne(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A placeholder with nothing to fill it is refused before anything is written.
|
||||
//
|
||||
// The alternative is a configuration file containing the literal `${secret:x}`, which the program
|
||||
// reads as a value and fails on somewhere with no connection to this.
|
||||
func TestAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) {
|
||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||
{"id":"c","type":"file","path":"/tmp/x","content":"token=${secret:absent}"}
|
||||
]}`)
|
||||
if len(refusal.Problems) == 0 {
|
||||
t.Fatal("a file naming a secret nobody gave it was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// And a secret nobody asked for, because somebody believes it is in a file where it is not.
|
||||
func TestASecretTheContentNeverUsesIsRefused(t *testing.T) {
|
||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||
{"id":"c","type":"file","path":"/tmp/x","content":"nothing here","secrets":{"spare":"S"}}
|
||||
]}`)
|
||||
if len(refusal.Problems) == 0 {
|
||||
t.Fatal("a secret the content never mentions was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user