Files
mesh-catalog/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/template.go
T

195 lines
5.5 KiB
Go

package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
)
// A module's templates, fetched by digest (novox/hq ADR 0349 clause 2, design 57 P5): the archive its build packed
// its templates directory into, fetched from where the plan says and used only when it hashes to the plan's
// digest — the bytes come from a network the mesh does not control, and only the digest makes them the ones the
// mesh built. Kept in the client's state by digest, so a node that cannot reach the artifact store still fills
// from what it fetched.
// maxTemplates bounds a templates archive: configuration text, never a theme.
const maxTemplates = 32 << 20
// Fetcher fetches an archive's bytes from its source.
type Fetcher func(ctx context.Context, source string) ([]byte, error)
// HTTPFetch is the artifact store, over HTTP.
func HTTPFetch(ctx context.Context, source string) ([]byte, error) {
if !strings.HasPrefix(source, "https://") && !strings.HasPrefix(source, "http://") {
return nil, fmt.Errorf("%s is not an address the templates can be fetched from", source)
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot fetch %s: %w", source, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetching %s: %s", source, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxTemplates+1))
if err != nil {
return nil, err
}
if len(body) > maxTemplates {
return nil, fmt.Errorf("%s is over %d bytes, more than a module's templates", source, maxTemplates)
}
return body, nil
}
// templateStore keeps fetched archives by digest under dir, and their texts in memory.
type templateStore struct {
dir string
fetch Fetcher
texts map[string]map[string]string
}
// load is every template's text of the archive the plan names, by its path inside the archive.
func (s *templateStore) load(ctx context.Context, t Templates) (map[string]string, error) {
if texts, ok := s.texts[t.Digest]; ok {
return texts, nil
}
kept := filepath.Join(s.dir, strings.TrimPrefix(t.Digest, "sha256:")+".tar.gz")
body, err := os.ReadFile(kept)
if err != nil || digestOf(body) != t.Digest {
if body, err = s.fetch(ctx, t.Source); err != nil {
return nil, err
}
if got := digestOf(body); got != t.Digest {
return nil, fmt.Errorf("the templates at %s are %s, and the plan names %s: nothing was filled from them",
t.Source, got, t.Digest)
}
if err := writeKept(kept, body); err != nil {
// Said and gone on: the archive is in hand, and is fetched again next start.
logf("[provisioner-client] the templates %s could not be kept at %s: %v", t.Digest, kept, err)
}
}
texts, err := unpackTexts(body)
if err != nil {
return nil, fmt.Errorf("the templates %s cannot be read: %w", t.Digest, err)
}
if s.texts == nil {
s.texts = map[string]map[string]string{}
}
s.texts[t.Digest] = texts
return texts, nil
}
func digestOf(b []byte) string {
sum := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(sum[:])
}
func writeKept(at string, body []byte) error {
if err := os.MkdirAll(filepath.Dir(at), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(at), ".templates-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(body); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), at)
}
// unpackTexts is every regular file of a gzipped tar, by its clean path inside it.
func unpackTexts(body []byte) (map[string]string, error) {
zipped, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer zipped.Close()
r := tar.NewReader(zipped)
out := map[string]string{}
for {
h, err := r.Next()
if errors.Is(err, io.EOF) {
return out, nil
}
if err != nil {
return nil, err
}
if h.Typeflag != tar.TypeReg {
continue
}
b, err := io.ReadAll(io.LimitReader(r, maxTemplates))
if err != nil {
return nil, err
}
out[strings.TrimPrefix(path.Clean(h.Name), "./")] = string(b)
}
}
// placeholder is `${<kind>:<name>}`, as ADR 0349 clause 2 writes one.
var placeholder = regexp.MustCompile(`\$\{([a-z][a-z0-9-]*):([^}]+)\}`)
// Need is one value a template names.
type Need struct {
Kind string
Name string
}
func (n Need) String() string { return n.Kind + ":" + n.Name }
// needsOf is every placeholder a text names, in the order each first appears.
func needsOf(text string) []Need {
var out []Need
seen := map[Need]bool{}
for _, m := range placeholder.FindAllStringSubmatch(text, -1) {
n := Need{Kind: m[1], Name: m[2]}
if !seen[n] {
seen[n] = true
out = append(out, n)
}
}
return out
}
// render fills every placeholder of text from values, which holds each.
func render(text string, values map[Need]string) string {
return placeholder.ReplaceAllStringFunc(text, func(ref string) string {
m := placeholder.FindStringSubmatch(ref)
return values[Need{Kind: m[1], Name: m[2]}]
})
}
// configText is a config value as a file takes it: a string as its text, anything else as its JSON.
func configText(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
return string(raw)
}