security: publish hardened v1 initiative snapshot
Publish the reviewed security policy and evidence, exact runtime ABI enforcement, orphan-output and permission safeguards, dead-upstream cleanup, and the evidence-gated v1 launch plan. This commit is an exact sanitized export from the private development record. Material implementation and review were assisted by OpenAI Codex; Cole Speelman reviewed the changes and accepts human responsibility. Himesan-Output-Permission: v1.0 Signed-off-by: Cole Speelman <gamertan@noreply.localhost>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratedCodeRequiresVersionedRuntimeABIMarker(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping temporary-module ABI compilation in short mode")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
directory := resolvedTempDir(t)
|
||||
templatePath := filepath.Join(directory, "page.sando")
|
||||
mustWrite(t, templatePath, "<?sando go\npackage generated\nfunc Page()\n?>")
|
||||
result, err := Generate(context.Background(), []string{templatePath})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v (%v)", err, result.Diagnostics)
|
||||
}
|
||||
generated := string(mustRead(t, templatePath+".go"))
|
||||
if !strings.Contains(generated, ".ABISandoV1") {
|
||||
t.Fatalf("generated code does not require the sando.v1 marker:\n%s", generated)
|
||||
}
|
||||
|
||||
mustWrite(t, filepath.Join(directory, "go.mod"), `module example.test/abi
|
||||
|
||||
go 1.25
|
||||
|
||||
require gamertan.com/sandwich-hime/sando v0.0.0
|
||||
|
||||
replace gamertan.com/sandwich-hime/sando => ./fake-sando
|
||||
`)
|
||||
mustWrite(t, filepath.Join(directory, "fake-sando", "go.mod"), `module gamertan.com/sandwich-hime/sando
|
||||
|
||||
go 1.25
|
||||
`)
|
||||
mustWrite(t, filepath.Join(directory, "fake-sando", "component.go"), `package sando
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
const ABI = "sando.incompatible"
|
||||
|
||||
type Component interface { Render(context.Context, io.Writer) error }
|
||||
type ComponentFunc func(context.Context, io.Writer) error
|
||||
func (f ComponentFunc) Render(ctx context.Context, w io.Writer) error { return f(ctx, w) }
|
||||
`)
|
||||
|
||||
command := exec.Command("go", "test", "./...")
|
||||
command.Dir = directory
|
||||
command.Env = append(os.Environ(), "GOWORK=off")
|
||||
output, buildErr := command.CombinedOutput()
|
||||
if buildErr == nil {
|
||||
t.Fatalf("generated code compiled against an incompatible runtime:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(string(output), "undefined: __himesan_sando.ABISandoV1") {
|
||||
t.Fatalf("incompatible runtime failed for an unexpected reason: %v\n%s", buildErr, output)
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,10 @@ func generateGo(file *sourceFile) ([]byte, []Diagnostic) {
|
||||
}
|
||||
}
|
||||
output.WriteString(")\n\n")
|
||||
fmt.Fprintf(&output, "var _ = %s.ABI\n\n", imports.Sando)
|
||||
// A version-specific exported marker makes the generated/runtime ABI a Go
|
||||
// build-time contract. The descriptive ABI string alone cannot enforce
|
||||
// compatibility because constant values are not part of symbol resolution.
|
||||
fmt.Fprintf(&output, "var _ = %s.ABISandoV1\n\n", imports.Sando)
|
||||
fmt.Fprintf(&output, "func %s%s%s %s.Component {\n", file.Name, file.TypeParams, file.Params, imports.Sando)
|
||||
fmt.Fprintf(&output, "\treturn %s.ComponentFunc(func(%s %s.Context, %s %s.Writer) error {\n", imports.Sando, contextName, imports.Context, writerName, imports.IO)
|
||||
fmt.Fprintf(&output, "\t\t_ = %s\n", contextName)
|
||||
|
||||
@@ -340,6 +340,68 @@ func TestGenerateRefusesUnownedOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryOperationsRejectOrphanedOwnedOutputBeforeWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
orphanSource := filepath.Join(directory, "orphan.sando")
|
||||
mustWrite(t, orphanSource, simpleSource("Orphan", "last good"))
|
||||
if _, err := Generate(context.Background(), []string{directory}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orphanOutput := orphanSource + ".go"
|
||||
lastGood := mustRead(t, orphanOutput)
|
||||
if err := os.Remove(orphanSource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
liveSource := filepath.Join(directory, "live.sando")
|
||||
mustWrite(t, liveSource, simpleSource("Live", "must not be written"))
|
||||
// A handwritten file whose name merely resembles an output is not owned by
|
||||
// Hime-san and must not be treated as an orphan.
|
||||
mustWrite(t, filepath.Join(directory, "handwritten.sando.go"), "package demo\n")
|
||||
|
||||
checked, err := Check(context.Background(), []string{directory})
|
||||
if err == nil {
|
||||
t.Fatalf("orphaned owned output unexpectedly passed check: %+v", checked)
|
||||
}
|
||||
assertDiagnosticCode(t, checked.Diagnostics, "HIM2014")
|
||||
|
||||
generated, err := Generate(context.Background(), []string{directory})
|
||||
if err == nil {
|
||||
t.Fatalf("orphaned owned output unexpectedly allowed generation: %+v", generated)
|
||||
}
|
||||
assertDiagnosticCode(t, generated.Diagnostics, "HIM2014")
|
||||
if !bytes.Equal(lastGood, mustRead(t, orphanOutput)) {
|
||||
t.Fatal("orphaned last-good output changed")
|
||||
}
|
||||
if _, statErr := os.Stat(liveSource + ".go"); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("batch wrote a live output despite the orphan diagnostic: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGeneratedOutputInheritsRestrictiveSourceMode(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX file mode test")
|
||||
}
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
path := filepath.Join(directory, "private.sando")
|
||||
mustWrite(t, path, simpleSource("Private", "private"))
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Generate(context.Background(), []string{path}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path + ".go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("generated output mode = %04o, want 0600", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryBoundariesAndExplicitNestedFile(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation commonly requires additional Windows privileges")
|
||||
|
||||
@@ -34,7 +34,8 @@ const (
|
||||
)
|
||||
|
||||
type contextAnalyzer struct {
|
||||
file *sourceFile
|
||||
file *sourceFile
|
||||
positions positionTable
|
||||
|
||||
state htmlState
|
||||
currentTag string
|
||||
@@ -79,7 +80,12 @@ var unsupportedDynamicAttributes = map[string]string{
|
||||
}
|
||||
|
||||
func analyzeContexts(file *sourceFile) []Diagnostic {
|
||||
analyzer := &contextAnalyzer{file: file, state: htmlData, attrFirstDynamic: -1}
|
||||
analyzer := &contextAnalyzer{
|
||||
file: file,
|
||||
positions: newPositionTable(file.Source),
|
||||
state: htmlData,
|
||||
attrFirstDynamic: -1,
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
for nodeIndex := range file.Nodes {
|
||||
node := &file.Nodes[nodeIndex]
|
||||
@@ -136,20 +142,20 @@ func analyzeContexts(file *sourceFile) []Diagnostic {
|
||||
}
|
||||
}
|
||||
|
||||
end := analyzer.positions.at(len(file.Source))
|
||||
if analyzer.state != htmlData {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, endPosition(file.Source), "HIM1310", "template ends in an incomplete or ambiguous HTML parser context"))
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, end, "HIM1310", "template ends in an incomplete or ambiguous HTML parser context"))
|
||||
}
|
||||
if len(analyzer.stack) != 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, endPosition(file.Source), "HIM1311", fmt.Sprintf("component must finish in its starting HTML context; unclosed <%s>", analyzer.stack[len(analyzer.stack)-1])))
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, end, "HIM1311", fmt.Sprintf("component must finish in its starting HTML context; unclosed <%s>", analyzer.stack[len(analyzer.stack)-1])))
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) consumeText(text string, start sourcePosition) *Diagnostic {
|
||||
positionTable := newPositionTable(a.file.Source)
|
||||
for index := 0; index < len(text); index++ {
|
||||
b := text[index]
|
||||
position := positionTable.at(start.Offset + index)
|
||||
position := a.positions.at(start.Offset + index)
|
||||
if a.rawTag == "script" {
|
||||
a.scriptTail += string(b)
|
||||
if len(a.scriptTail) > len("<!--") {
|
||||
@@ -614,7 +620,3 @@ func isAttributeNameChar(b byte) bool {
|
||||
func isHTMLSpace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\r' || b == '\n' || b == '\f'
|
||||
}
|
||||
|
||||
func endPosition(source []byte) sourcePosition {
|
||||
return newPositionTable(source).at(len(source))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ package compiler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -115,7 +117,24 @@ func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sando" {
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(entry.Name(), ".sando.go") {
|
||||
entryInfo, statErr := entry.Info()
|
||||
if statErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2013", "cannot inspect possible generated output: "+statErr.Error()))
|
||||
return nil
|
||||
}
|
||||
if !entryInfo.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
if orphanDiagnostic := inspectOwnedGeneratedOutput(path); orphanDiagnostic != nil {
|
||||
diagnostics = append(diagnostics, *orphanDiagnostic)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(entry.Name()) != ".sando" {
|
||||
return nil
|
||||
}
|
||||
entryInfo, statErr := entry.Info()
|
||||
@@ -157,6 +176,50 @@ func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
|
||||
return discovered, diagnostics
|
||||
}
|
||||
|
||||
func inspectOwnedGeneratedOutput(path string) *Diagnostic {
|
||||
owned, err := hasGeneratedMarker(path)
|
||||
if err != nil {
|
||||
item := diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2013", "cannot inspect possible generated output: "+err.Error())
|
||||
return &item
|
||||
}
|
||||
if !owned {
|
||||
return nil
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSuffix(path, ".go")
|
||||
info, err := os.Lstat(sourcePath)
|
||||
if err == nil && info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
message := "owned generated output is orphaned because its adjacent .sando source is missing; review and remove the output explicitly"
|
||||
if err == nil {
|
||||
message = "owned generated output is orphaned because its adjacent .sando source is not a regular file; review and remove the output explicitly"
|
||||
} else if !os.IsNotExist(err) {
|
||||
message = "cannot inspect the adjacent .sando source for an owned generated output: " + err.Error()
|
||||
}
|
||||
item := diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2014", message)
|
||||
return &item
|
||||
}
|
||||
|
||||
func hasGeneratedMarker(path string) (bool, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
marker := []byte(generatedPrefix + "\n")
|
||||
prefix := make([]byte, len(marker))
|
||||
if _, err := io.ReadFull(file, prefix); err != nil {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return string(prefix) == string(marker), nil
|
||||
}
|
||||
|
||||
func firstSymlinkComponent(path string) (string, error) {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -86,6 +86,7 @@ type CompiledFile struct {
|
||||
Digest string
|
||||
Code []byte
|
||||
source *sourceFile
|
||||
sourceMode uint32
|
||||
}
|
||||
|
||||
// FileResult describes one source/output pair processed by Generate or Check.
|
||||
|
||||
@@ -61,7 +61,10 @@ func Generate(ctx context.Context, paths []string) (Result, error) {
|
||||
result.Unchanged++
|
||||
continue
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
// Generated Go can contain every literal present in its source. A new
|
||||
// output therefore must not be more permissive than the source file.
|
||||
// Execute bits are never meaningful for Go source and are stripped.
|
||||
mode := os.FileMode(file.sourceMode) & 0o666
|
||||
if info, statErr := os.Stat(file.OutputPath); statErr == nil {
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
@@ -151,6 +154,7 @@ func compileOperation(ctx context.Context, paths []string) ([]CompiledFile, Resu
|
||||
output, diagnostics := compileWithMapping(sourcePath, source, moduleRelativeSourcePath(sourcePath))
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostics...)
|
||||
if output.Code != nil {
|
||||
output.sourceMode = uint32(info.Mode().Perm())
|
||||
compiled = append(compiled, output)
|
||||
result.Files = append(result.Files, FileResult{SourcePath: output.SourcePath, OutputPath: output.OutputPath})
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
||||
__himesan_io "io"
|
||||
)
|
||||
|
||||
var _ = __himesan_sando.ABI
|
||||
var _ = __himesan_sando.ABISandoV1
|
||||
|
||||
func Greeting(name string) __himesan_sando.Component {
|
||||
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
|
||||
|
||||
@@ -104,6 +104,22 @@ func (d *developmentProxy) setTarget(address string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearTarget forgets address only when it is still the selected upstream.
|
||||
// The compare-and-swap prevents a concurrently replaced target from being
|
||||
// cleared between the load and store. The supervisor serializes activation
|
||||
// and exit handling and calls this only for its current candidate.
|
||||
func (d *developmentProxy) clearTarget(address string) bool {
|
||||
target := d.target.Load()
|
||||
if target == nil || target.Host != address {
|
||||
return false
|
||||
}
|
||||
if !d.target.CompareAndSwap(target, nil) {
|
||||
return false
|
||||
}
|
||||
d.closeIdleConnections()
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *developmentProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if status, message := d.validateRequest(r); status != 0 {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
@@ -250,6 +250,26 @@ func TestWaitingPageConnectsToEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearTargetOnlyClearsSelectedUpstream(t *testing.T) {
|
||||
t.Parallel()
|
||||
proxy := newDevelopmentProxy(newEventHub())
|
||||
if err := proxy.setTarget("127.0.0.1:7001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if proxy.clearTarget("127.0.0.1:7002") {
|
||||
t.Fatal("clearTarget cleared a different selected upstream")
|
||||
}
|
||||
if target := proxy.target.Load(); target == nil || target.Host != "127.0.0.1:7001" {
|
||||
t.Fatalf("selected upstream changed unexpectedly: %v", target)
|
||||
}
|
||||
if !proxy.clearTarget("127.0.0.1:7001") {
|
||||
t.Fatal("clearTarget did not clear the selected upstream")
|
||||
}
|
||||
if target := proxy.target.Load(); target != nil {
|
||||
t.Fatalf("selected upstream remains after clear: %v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentProxyRequiresLocalAuthorityAndSameOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
var upstreamRequests atomic.Int32
|
||||
|
||||
@@ -208,6 +208,7 @@ func (s *Supervisor) Run(ctx context.Context) error {
|
||||
}()
|
||||
|
||||
var current *candidateProcess
|
||||
var currentExited <-chan struct{}
|
||||
defer func() {
|
||||
s.hub.close()
|
||||
s.proxy.closeIdleConnections()
|
||||
@@ -224,6 +225,9 @@ func (s *Supervisor) Run(ctx context.Context) error {
|
||||
s.emit(Event{Type: "ready", Phase: "proxy", Message: "http://" + listener.Addr().String()})
|
||||
if candidate := s.buildHealthyCandidate(ctx); candidate != nil {
|
||||
current = s.activateCandidate(candidate, current)
|
||||
if current != nil {
|
||||
currentExited = current.exited
|
||||
}
|
||||
}
|
||||
|
||||
roots := makeWatchRoots(s.rootDir, s.options.Config)
|
||||
@@ -242,6 +246,25 @@ func (s *Supervisor) Run(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-currentExited:
|
||||
exited := current
|
||||
current = nil
|
||||
currentExited = nil
|
||||
if exited == nil {
|
||||
continue
|
||||
}
|
||||
// Forget the selected upstream before reporting or cleanup. Future
|
||||
// requests receive the waiting page and cannot follow a reused port.
|
||||
s.proxy.clearTarget(exited.address)
|
||||
exitErr := exited.result()
|
||||
if exitErr == nil {
|
||||
exitErr = errors.New("application exited")
|
||||
} else {
|
||||
exitErr = fmt.Errorf("application exited: %w", exitErr)
|
||||
}
|
||||
_ = exited.cleanupProcessTree()
|
||||
_ = os.Remove(exited.binaryPath)
|
||||
s.report("run", exitErr)
|
||||
case err := <-serverErrors:
|
||||
if err != nil {
|
||||
return fmt.Errorf("development proxy: %w", err)
|
||||
@@ -262,22 +285,15 @@ func (s *Supervisor) Run(ctx context.Context) error {
|
||||
pending = true
|
||||
changedAt = now
|
||||
}
|
||||
if current != nil && current.hasExited() {
|
||||
exitErr := current.result()
|
||||
if exitErr == nil {
|
||||
exitErr = errors.New("application exited")
|
||||
} else {
|
||||
exitErr = fmt.Errorf("application exited: %w", exitErr)
|
||||
}
|
||||
s.report("run", exitErr)
|
||||
_ = current.cleanupProcessTree()
|
||||
_ = os.Remove(current.binaryPath)
|
||||
current = nil
|
||||
}
|
||||
if pending && now.Sub(changedAt) >= s.options.Debounce {
|
||||
pending = false
|
||||
if candidate := s.buildHealthyCandidate(ctx); candidate != nil {
|
||||
current = s.activateCandidate(candidate, current)
|
||||
if current != nil {
|
||||
currentExited = current.exited
|
||||
} else {
|
||||
currentExited = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,73 @@ func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
|
||||
waitForConnectionRefused(t, secondUpstream, 3*time.Second)
|
||||
}
|
||||
|
||||
func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("integration test builds a temporary Go application")
|
||||
}
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/himesan-dev-exit-test\n\ngo 1.25\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := filepath.Join(root, "main.go")
|
||||
writeExitingTestApplication(t, mainPath, "short lived", 1500*time.Millisecond)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.ProxyAddress = "127.0.0.1:0"
|
||||
cfg.HealthPath = "/healthz"
|
||||
events := make(chan Event, 16)
|
||||
supervisor, err := New(Options{
|
||||
RootDir: root,
|
||||
Config: cfg,
|
||||
Generate: func(context.Context) error { return nil },
|
||||
OnEvent: func(event Event) { events <- event },
|
||||
CacheDir: filepath.Join(t.TempDir(), "cache"),
|
||||
PollInterval: 30 * time.Second,
|
||||
Debounce: 25 * time.Millisecond,
|
||||
BuildTimeout: 30 * time.Second,
|
||||
StartupTimeout: time.Second,
|
||||
ShutdownTimeout: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
runResult := make(chan error, 1)
|
||||
go func() { runResult <- supervisor.Run(ctx) }()
|
||||
t.Cleanup(cancel)
|
||||
|
||||
proxyAddress := waitForProxyAddress(t, supervisor)
|
||||
waitForBody(t, "http://"+proxyAddress+"/", "short lived")
|
||||
waitForPhase(t, events, "run")
|
||||
if target := supervisor.proxy.target.Load(); target != nil {
|
||||
t.Fatalf("proxy retained exited upstream %v", target)
|
||||
}
|
||||
|
||||
client := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: time.Second}
|
||||
response, err := client.Get("http://" + proxyAddress + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
_ = response.Body.Close()
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if response.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(body), "waiting for a healthy application") {
|
||||
t.Fatalf("dead upstream response = %d %q", response.StatusCode, body)
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-runResult:
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Run() did not stop after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationFailureDoesNotMoveProxyTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
upstream := http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -173,6 +240,38 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func writeExitingTestApplication(t *testing.T, path, message string, lifetime time.Duration) {
|
||||
t.Helper()
|
||||
contents := fmt.Sprintf(`package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
time.Sleep(%d * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, "<!doctype html><html><body>%s</body></html>")
|
||||
})
|
||||
if err := http.ListenAndServe(os.Getenv("HIMESAN_LISTEN_ADDR"), mux); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
`, lifetime.Milliseconds(), message)
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPhase(t *testing.T, events <-chan Event, phase string) {
|
||||
t.Helper()
|
||||
timer := time.NewTimer(10 * time.Second)
|
||||
|
||||
Reference in New Issue
Block a user