diff --git a/envd/Makefile b/envd/Makefile index 7b861c3..b3af722 100644 --- a/envd/Makefile +++ b/envd/Makefile @@ -1,17 +1,62 @@ -LDFLAGS := -s -w +BUILD := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +LDFLAGS := -s -w -X=main.commitSHA=$(BUILD) +BUILDS := ../builds -.PHONY: build clean fmt vet +# ═══════════════════════════════════════════════════ +# Build +# ═══════════════════════════════════════════════════ +.PHONY: build build-debug build: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o envd . - @file envd | grep -q "statically linked" || \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(BUILDS)/envd . + @file $(BUILDS)/envd | grep -q "statically linked" || \ (echo "ERROR: envd is not statically linked!" && exit 1) -clean: - rm -f envd +build-debug: + CGO_ENABLED=1 go build -race -gcflags=all="-N -l" -ldflags="-X=main.commitSHA=$(BUILD)" -o $(BUILDS)/debug/envd . + +# ═══════════════════════════════════════════════════ +# Run (debug mode, not inside a VM) +# ═══════════════════════════════════════════════════ +.PHONY: run-debug + +run-debug: build-debug + $(BUILDS)/debug/envd -isnotfc -port 49983 + +# ═══════════════════════════════════════════════════ +# Code Generation +# ═══════════════════════════════════════════════════ +.PHONY: generate proto openapi + +generate: proto openapi + +proto: + cd spec && buf generate --template buf.gen.yaml + +openapi: + go generate ./internal/api/... + +# ═══════════════════════════════════════════════════ +# Quality +# ═══════════════════════════════════════════════════ +.PHONY: fmt vet test tidy fmt: gofmt -w . vet: go vet ./... + +test: + go test -race -v ./... + +tidy: + go mod tidy + +# ═══════════════════════════════════════════════════ +# Clean +# ═══════════════════════════════════════════════════ +.PHONY: clean + +clean: + rm -f $(BUILDS)/envd $(BUILDS)/debug/envd diff --git a/envd/go.mod b/envd/go.mod index da104fe..be2c95a 100644 --- a/envd/go.mod +++ b/envd/go.mod @@ -1,9 +1,42 @@ -module github.com/wrenn-dev/envd +module git.omukk.dev/wrenn/sandbox/envd -go 1.23.0 +go 1.25.5 require ( - github.com/mdlayher/vsock v1.2.1 - google.golang.org/grpc v1.71.0 - google.golang.org/protobuf v1.36.5 + connectrpc.com/authn v0.1.0 + connectrpc.com/connect v1.19.1 + connectrpc.com/cors v0.1.0 + github.com/awnumar/memguard v0.23.0 + github.com/creack/pty v1.1.24 + github.com/dchest/uniuri v1.2.0 + github.com/e2b-dev/fsnotify v0.0.1 + github.com/go-chi/chi/v5 v5.2.5 + github.com/google/uuid v1.6.0 + github.com/oapi-codegen/runtime v1.2.0 + github.com/orcaman/concurrent-map/v2 v2.0.1 + github.com/rs/cors v1.11.1 + github.com/rs/zerolog v1.34.0 + github.com/shirou/gopsutil/v4 v4.26.2 + github.com/stretchr/testify v1.11.1 + github.com/txn2/txeh v1.8.0 + golang.org/x/sys v0.42.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/awnumar/memcall v0.4.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/crypto v0.41.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/envd/go.sum b/envd/go.sum new file mode 100644 index 0000000..a051cf5 --- /dev/null +++ b/envd/go.sum @@ -0,0 +1,92 @@ +connectrpc.com/authn v0.1.0 h1:m5weACjLWwgwcjttvUDyTPICJKw74+p2obBVrf8hT9E= +connectrpc.com/authn v0.1.0/go.mod h1:AwNZK/KYbqaJzRYadTuAaoz6sYQSPdORPqh1TOPIkgY= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +connectrpc.com/cors v0.1.0 h1:f3gTXJyDZPrDIZCQ567jxfD9PAIpopHiRDnJRt3QuOQ= +connectrpc.com/cors v0.1.0/go.mod h1:v8SJZCPfHtGH1zsm+Ttajpozd4cYIUryl4dFB6QEpfg= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g= +github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w= +github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A= +github.com/awnumar/memguard v0.23.0/go.mod h1:olVofBrsPdITtJ2HgxQKrEYEMyIBAIciVG4wNnZhW9M= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= +github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= +github.com/e2b-dev/fsnotify v0.0.1 h1:7j0I98HD6VehAuK/bcslvW4QDynAULtOuMZtImihjVk= +github.com/e2b-dev/fsnotify v0.0.1/go.mod h1:jAuDjregRrUixKneTRQwPI847nNuPFg3+n5QM/ku/JM= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4= +github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0= +github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c= +github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/txn2/txeh v1.8.0 h1:G1vZgom6+P/xWwU53AMOpcZgC5ni382ukcPP1TDVYHk= +github.com/txn2/txeh v1.8.0/go.mod h1:rRI3Egi3+AFmEXQjft051YdYbxeCT3nFmBLsNCZZaxM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/envd/internal/api/api.gen.go b/envd/internal/api/api.gen.go new file mode 100644 index 0000000..512747b --- /dev/null +++ b/envd/internal/api/api.gen.go @@ -0,0 +1,568 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. +package api + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +const ( + AccessTokenAuthScopes = "AccessTokenAuth.Scopes" +) + +// Defines values for EntryInfoType. +const ( + File EntryInfoType = "file" +) + +// EntryInfo defines model for EntryInfo. +type EntryInfo struct { + // Name Name of the file + Name string `json:"name"` + + // Path Path to the file + Path string `json:"path"` + + // Type Type of the file + Type EntryInfoType `json:"type"` +} + +// EntryInfoType Type of the file +type EntryInfoType string + +// EnvVars Environment variables to set +type EnvVars map[string]string + +// Error defines model for Error. +type Error struct { + // Code Error code + Code int `json:"code"` + + // Message Error message + Message string `json:"message"` +} + +// Metrics Resource usage metrics +type Metrics struct { + // CpuCount Number of CPU cores + CpuCount *int `json:"cpu_count,omitempty"` + + // CpuUsedPct CPU usage percentage + CpuUsedPct *float32 `json:"cpu_used_pct,omitempty"` + + // DiskTotal Total disk space in bytes + DiskTotal *int `json:"disk_total,omitempty"` + + // DiskUsed Used disk space in bytes + DiskUsed *int `json:"disk_used,omitempty"` + + // MemTotal Total virtual memory in bytes + MemTotal *int `json:"mem_total,omitempty"` + + // MemUsed Used virtual memory in bytes + MemUsed *int `json:"mem_used,omitempty"` + + // Ts Unix timestamp in UTC for current sandbox time + Ts *int64 `json:"ts,omitempty"` +} + +// VolumeMount Volume +type VolumeMount struct { + NfsTarget string `json:"nfs_target"` + Path string `json:"path"` +} + +// FilePath defines model for FilePath. +type FilePath = string + +// Signature defines model for Signature. +type Signature = string + +// SignatureExpiration defines model for SignatureExpiration. +type SignatureExpiration = int + +// User defines model for User. +type User = string + +// FileNotFound defines model for FileNotFound. +type FileNotFound = Error + +// InternalServerError defines model for InternalServerError. +type InternalServerError = Error + +// InvalidPath defines model for InvalidPath. +type InvalidPath = Error + +// InvalidUser defines model for InvalidUser. +type InvalidUser = Error + +// NotEnoughDiskSpace defines model for NotEnoughDiskSpace. +type NotEnoughDiskSpace = Error + +// UploadSuccess defines model for UploadSuccess. +type UploadSuccess = []EntryInfo + +// GetFilesParams defines parameters for GetFiles. +type GetFilesParams struct { + // Path Path to the file, URL encoded. Can be relative to user's home directory. + Path *FilePath `form:"path,omitempty" json:"path,omitempty"` + + // Username User used for setting the owner, or resolving relative paths. + Username *User `form:"username,omitempty" json:"username,omitempty"` + + // Signature Signature used for file access permission verification. + Signature *Signature `form:"signature,omitempty" json:"signature,omitempty"` + + // SignatureExpiration Signature expiration used for defining the expiration time of the signature. + SignatureExpiration *SignatureExpiration `form:"signature_expiration,omitempty" json:"signature_expiration,omitempty"` +} + +// PostFilesMultipartBody defines parameters for PostFiles. +type PostFilesMultipartBody struct { + File *openapi_types.File `json:"file,omitempty"` +} + +// PostFilesParams defines parameters for PostFiles. +type PostFilesParams struct { + // Path Path to the file, URL encoded. Can be relative to user's home directory. + Path *FilePath `form:"path,omitempty" json:"path,omitempty"` + + // Username User used for setting the owner, or resolving relative paths. + Username *User `form:"username,omitempty" json:"username,omitempty"` + + // Signature Signature used for file access permission verification. + Signature *Signature `form:"signature,omitempty" json:"signature,omitempty"` + + // SignatureExpiration Signature expiration used for defining the expiration time of the signature. + SignatureExpiration *SignatureExpiration `form:"signature_expiration,omitempty" json:"signature_expiration,omitempty"` +} + +// PostInitJSONBody defines parameters for PostInit. +type PostInitJSONBody struct { + // AccessToken Access token for secure access to envd service + AccessToken *SecureToken `json:"accessToken,omitempty"` + + // DefaultUser The default user to use for operations + DefaultUser *string `json:"defaultUser,omitempty"` + + // DefaultWorkdir The default working directory to use for operations + DefaultWorkdir *string `json:"defaultWorkdir,omitempty"` + + // EnvVars Environment variables to set + EnvVars *EnvVars `json:"envVars,omitempty"` + + // HyperloopIP IP address of the hyperloop server to connect to + HyperloopIP *string `json:"hyperloopIP,omitempty"` + + // Timestamp The current timestamp in RFC3339 format + Timestamp *time.Time `json:"timestamp,omitempty"` + VolumeMounts *[]VolumeMount `json:"volumeMounts,omitempty"` +} + +// PostFilesMultipartRequestBody defines body for PostFiles for multipart/form-data ContentType. +type PostFilesMultipartRequestBody PostFilesMultipartBody + +// PostInitJSONRequestBody defines body for PostInit for application/json ContentType. +type PostInitJSONRequestBody PostInitJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Get the environment variables + // (GET /envs) + GetEnvs(w http.ResponseWriter, r *http.Request) + // Download a file + // (GET /files) + GetFiles(w http.ResponseWriter, r *http.Request, params GetFilesParams) + // Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. + // (POST /files) + PostFiles(w http.ResponseWriter, r *http.Request, params PostFilesParams) + // Check the health of the service + // (GET /health) + GetHealth(w http.ResponseWriter, r *http.Request) + // Set initial vars, ensure the time and metadata is synced with the host + // (POST /init) + PostInit(w http.ResponseWriter, r *http.Request) + // Get the stats of the service + // (GET /metrics) + GetMetrics(w http.ResponseWriter, r *http.Request) +} + +// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. + +type Unimplemented struct{} + +// Get the environment variables +// (GET /envs) +func (_ Unimplemented) GetEnvs(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Download a file +// (GET /files) +func (_ Unimplemented) GetFiles(w http.ResponseWriter, r *http.Request, params GetFilesParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. +// (POST /files) +func (_ Unimplemented) PostFiles(w http.ResponseWriter, r *http.Request, params PostFilesParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Check the health of the service +// (GET /health) +func (_ Unimplemented) GetHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Set initial vars, ensure the time and metadata is synced with the host +// (POST /init) +func (_ Unimplemented) PostInit(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Get the stats of the service +// (GET /metrics) +func (_ Unimplemented) GetMetrics(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// GetEnvs operation middleware +func (siw *ServerInterfaceWrapper) GetEnvs(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, AccessTokenAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetEnvs(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetFiles operation middleware +func (siw *ServerInterfaceWrapper) GetFiles(w http.ResponseWriter, r *http.Request) { + + var err error + + ctx := r.Context() + + ctx = context.WithValue(ctx, AccessTokenAuthScopes, []string{}) + + r = r.WithContext(ctx) + + // Parameter object where we will unmarshal all parameters from the context + var params GetFilesParams + + // ------------- Optional query parameter "path" ------------- + + err = runtime.BindQueryParameter("form", true, false, "path", r.URL.Query(), ¶ms.Path) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) + return + } + + // ------------- Optional query parameter "username" ------------- + + err = runtime.BindQueryParameter("form", true, false, "username", r.URL.Query(), ¶ms.Username) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "username", Err: err}) + return + } + + // ------------- Optional query parameter "signature" ------------- + + err = runtime.BindQueryParameter("form", true, false, "signature", r.URL.Query(), ¶ms.Signature) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "signature", Err: err}) + return + } + + // ------------- Optional query parameter "signature_expiration" ------------- + + err = runtime.BindQueryParameter("form", true, false, "signature_expiration", r.URL.Query(), ¶ms.SignatureExpiration) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "signature_expiration", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetFiles(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// PostFiles operation middleware +func (siw *ServerInterfaceWrapper) PostFiles(w http.ResponseWriter, r *http.Request) { + + var err error + + ctx := r.Context() + + ctx = context.WithValue(ctx, AccessTokenAuthScopes, []string{}) + + r = r.WithContext(ctx) + + // Parameter object where we will unmarshal all parameters from the context + var params PostFilesParams + + // ------------- Optional query parameter "path" ------------- + + err = runtime.BindQueryParameter("form", true, false, "path", r.URL.Query(), ¶ms.Path) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) + return + } + + // ------------- Optional query parameter "username" ------------- + + err = runtime.BindQueryParameter("form", true, false, "username", r.URL.Query(), ¶ms.Username) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "username", Err: err}) + return + } + + // ------------- Optional query parameter "signature" ------------- + + err = runtime.BindQueryParameter("form", true, false, "signature", r.URL.Query(), ¶ms.Signature) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "signature", Err: err}) + return + } + + // ------------- Optional query parameter "signature_expiration" ------------- + + err = runtime.BindQueryParameter("form", true, false, "signature_expiration", r.URL.Query(), ¶ms.SignatureExpiration) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "signature_expiration", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostFiles(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetHealth operation middleware +func (siw *ServerInterfaceWrapper) GetHealth(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetHealth(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// PostInit operation middleware +func (siw *ServerInterfaceWrapper) PostInit(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, AccessTokenAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostInit(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetMetrics operation middleware +func (siw *ServerInterfaceWrapper) GetMetrics(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, AccessTokenAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetMetrics(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/envs", wrapper.GetEnvs) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/files", wrapper.GetFiles) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/files", wrapper.PostFiles) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/health", wrapper.GetHealth) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/init", wrapper.PostInit) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/metrics", wrapper.GetMetrics) + }) + + return r +} diff --git a/envd/internal/api/auth.go b/envd/internal/api/auth.go new file mode 100644 index 0000000..0e85954 --- /dev/null +++ b/envd/internal/api/auth.go @@ -0,0 +1,129 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/awnumar/memguard" + + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/keys" +) + +const ( + SigningReadOperation = "read" + SigningWriteOperation = "write" + + accessTokenHeader = "X-Access-Token" +) + +// paths that are always allowed without general authentication +// POST/init is secured via MMDS hash validation instead +var authExcludedPaths = []string{ + "GET/health", + "GET/files", + "POST/files", + "POST/init", +} + +func (a *API) WithAuthorization(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if a.accessToken.IsSet() { + authHeader := req.Header.Get(accessTokenHeader) + + // check if this path is allowed without authentication (e.g., health check, endpoints supporting signing) + allowedPath := slices.Contains(authExcludedPaths, req.Method+req.URL.Path) + + if !a.accessToken.Equals(authHeader) && !allowedPath { + a.logger.Error().Msg("Trying to access secured envd without correct access token") + + err := fmt.Errorf("unauthorized access, please provide a valid access token or method signing if supported") + jsonError(w, http.StatusUnauthorized, err) + + return + } + } + + handler.ServeHTTP(w, req) + }) +} + +func (a *API) generateSignature(path string, username string, operation string, signatureExpiration *int64) (string, error) { + tokenBytes, err := a.accessToken.Bytes() + if err != nil { + return "", fmt.Errorf("access token is not set: %w", err) + } + defer memguard.WipeBytes(tokenBytes) + + var signature string + hasher := keys.NewSHA256Hashing() + + if signatureExpiration == nil { + signature = strings.Join([]string{path, operation, username, string(tokenBytes)}, ":") + } else { + signature = strings.Join([]string{path, operation, username, string(tokenBytes), strconv.FormatInt(*signatureExpiration, 10)}, ":") + } + + return fmt.Sprintf("v1_%s", hasher.HashWithoutPrefix([]byte(signature))), nil +} + +func (a *API) validateSigning(r *http.Request, signature *string, signatureExpiration *int, username *string, path string, operation string) (err error) { + var expectedSignature string + + // no need to validate signing key if access token is not set + if !a.accessToken.IsSet() { + return nil + } + + // check if access token is sent in the header + tokenFromHeader := r.Header.Get(accessTokenHeader) + if tokenFromHeader != "" { + if !a.accessToken.Equals(tokenFromHeader) { + return fmt.Errorf("access token present in header but does not match") + } + + return nil + } + + if signature == nil { + return fmt.Errorf("missing signature query parameter") + } + + // Empty string is used when no username is provided and the default user should be used + signatureUsername := "" + if username != nil { + signatureUsername = *username + } + + if signatureExpiration == nil { + expectedSignature, err = a.generateSignature(path, signatureUsername, operation, nil) + } else { + exp := int64(*signatureExpiration) + expectedSignature, err = a.generateSignature(path, signatureUsername, operation, &exp) + } + + if err != nil { + a.logger.Error().Err(err).Msg("error generating signing key") + + return errors.New("invalid signature") + } + + // signature validation + if expectedSignature != *signature { + return fmt.Errorf("invalid signature") + } + + // signature expiration + if signatureExpiration != nil { + exp := int64(*signatureExpiration) + if exp < time.Now().Unix() { + return fmt.Errorf("signature is already expired") + } + } + + return nil +} diff --git a/envd/internal/api/auth_test.go b/envd/internal/api/auth_test.go new file mode 100644 index 0000000..00b9221 --- /dev/null +++ b/envd/internal/api/auth_test.go @@ -0,0 +1,62 @@ +package api + +import ( + "fmt" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/keys" +) + +func TestKeyGenerationAlgorithmIsStable(t *testing.T) { + t.Parallel() + apiToken := "secret-access-token" + secureToken := &SecureToken{} + err := secureToken.Set([]byte(apiToken)) + require.NoError(t, err) + api := &API{accessToken: secureToken} + + path := "/path/to/demo.txt" + username := "root" + operation := "write" + timestamp := time.Now().Unix() + + signature, err := api.generateSignature(path, username, operation, ×tamp) + require.NoError(t, err) + assert.NotEmpty(t, signature) + + // locally generated signature + hasher := keys.NewSHA256Hashing() + localSignatureTmp := fmt.Sprintf("%s:%s:%s:%s:%s", path, operation, username, apiToken, strconv.FormatInt(timestamp, 10)) + localSignature := fmt.Sprintf("v1_%s", hasher.HashWithoutPrefix([]byte(localSignatureTmp))) + + assert.Equal(t, localSignature, signature) +} + +func TestKeyGenerationAlgorithmWithoutExpirationIsStable(t *testing.T) { + t.Parallel() + apiToken := "secret-access-token" + secureToken := &SecureToken{} + err := secureToken.Set([]byte(apiToken)) + require.NoError(t, err) + api := &API{accessToken: secureToken} + + path := "/path/to/resource.txt" + username := "user" + operation := "read" + + signature, err := api.generateSignature(path, username, operation, nil) + require.NoError(t, err) + assert.NotEmpty(t, signature) + + // locally generated signature + hasher := keys.NewSHA256Hashing() + localSignatureTmp := fmt.Sprintf("%s:%s:%s:%s", path, operation, username, apiToken) + localSignature := fmt.Sprintf("v1_%s", hasher.HashWithoutPrefix([]byte(localSignatureTmp))) + + assert.Equal(t, localSignature, signature) +} diff --git a/envd/internal/api/cfg.yaml b/envd/internal/api/cfg.yaml new file mode 100644 index 0000000..da46226 --- /dev/null +++ b/envd/internal/api/cfg.yaml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/deepmap/oapi-codegen/HEAD/configuration-schema.json + +package: api +output: api.gen.go +generate: + models: true + chi-server: true + client: false diff --git a/envd/internal/api/download.go b/envd/internal/api/download.go new file mode 100644 index 0000000..b643484 --- /dev/null +++ b/envd/internal/api/download.go @@ -0,0 +1,173 @@ +package api + +import ( + "compress/gzip" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "os/user" + "path/filepath" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" +) + +func (a *API) GetFiles(w http.ResponseWriter, r *http.Request, params GetFilesParams) { + defer r.Body.Close() + + var errorCode int + var errMsg error + + var path string + if params.Path != nil { + path = *params.Path + } + + operationID := logs.AssignOperationID() + + // signing authorization if needed + err := a.validateSigning(r, params.Signature, params.SignatureExpiration, params.Username, path, SigningReadOperation) + if err != nil { + a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("error during auth validation") + jsonError(w, http.StatusUnauthorized, err) + + return + } + + username, err := execcontext.ResolveDefaultUsername(params.Username, a.defaults.User) + if err != nil { + a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("no user specified") + jsonError(w, http.StatusBadRequest, err) + + return + } + + defer func() { + l := a.logger. + Err(errMsg). + Str("method", r.Method+" "+r.URL.Path). + Str(string(logs.OperationIDKey), operationID). + Str("path", path). + Str("username", username) + + if errMsg != nil { + l = l.Int("error_code", errorCode) + } + + l.Msg("File read") + }() + + u, err := user.Lookup(username) + if err != nil { + errMsg = fmt.Errorf("error looking up user '%s': %w", username, err) + errorCode = http.StatusUnauthorized + jsonError(w, errorCode, errMsg) + + return + } + + resolvedPath, err := permissions.ExpandAndResolve(path, u, a.defaults.Workdir) + if err != nil { + errMsg = fmt.Errorf("error expanding and resolving path '%s': %w", path, err) + errorCode = http.StatusBadRequest + jsonError(w, errorCode, errMsg) + + return + } + + stat, err := os.Stat(resolvedPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + errMsg = fmt.Errorf("path '%s' does not exist", resolvedPath) + errorCode = http.StatusNotFound + jsonError(w, errorCode, errMsg) + + return + } + + errMsg = fmt.Errorf("error checking if path exists '%s': %w", resolvedPath, err) + errorCode = http.StatusInternalServerError + jsonError(w, errorCode, errMsg) + + return + } + + if stat.IsDir() { + errMsg = fmt.Errorf("path '%s' is a directory", resolvedPath) + errorCode = http.StatusBadRequest + jsonError(w, errorCode, errMsg) + + return + } + + // Validate Accept-Encoding header + encoding, err := parseAcceptEncoding(r) + if err != nil { + errMsg = fmt.Errorf("error parsing Accept-Encoding: %w", err) + errorCode = http.StatusNotAcceptable + jsonError(w, errorCode, errMsg) + + return + } + + // Tell caches to store separate variants for different Accept-Encoding values + w.Header().Set("Vary", "Accept-Encoding") + + // Fall back to identity for Range or conditional requests to preserve http.ServeContent + // behavior (206 Partial Content, 304 Not Modified). However, we must check if identity + // is acceptable per the Accept-Encoding header. + hasRangeOrConditional := r.Header.Get("Range") != "" || + r.Header.Get("If-Modified-Since") != "" || + r.Header.Get("If-None-Match") != "" || + r.Header.Get("If-Range") != "" + if hasRangeOrConditional { + if !isIdentityAcceptable(r) { + errMsg = fmt.Errorf("identity encoding not acceptable for Range or conditional request") + errorCode = http.StatusNotAcceptable + jsonError(w, errorCode, errMsg) + + return + } + encoding = EncodingIdentity + } + + file, err := os.Open(resolvedPath) + if err != nil { + errMsg = fmt.Errorf("error opening file '%s': %w", resolvedPath, err) + errorCode = http.StatusInternalServerError + jsonError(w, errorCode, errMsg) + + return + } + defer file.Close() + + w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filepath.Base(resolvedPath)})) + + // Serve with gzip encoding if requested. + if encoding == EncodingGzip { + w.Header().Set("Content-Encoding", EncodingGzip) + + // Set Content-Type based on file extension, preserving the original type + contentType := mime.TypeByExtension(filepath.Ext(path)) + if contentType == "" { + contentType = "application/octet-stream" + } + w.Header().Set("Content-Type", contentType) + + gw := gzip.NewWriter(w) + defer gw.Close() + + _, err = io.Copy(gw, file) + if err != nil { + a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("error writing gzip response") + } + + return + } + + http.ServeContent(w, r, path, stat.ModTime(), file) +} diff --git a/envd/internal/api/download_test.go b/envd/internal/api/download_test.go new file mode 100644 index 0000000..e0f78b0 --- /dev/null +++ b/envd/internal/api/download_test.go @@ -0,0 +1,401 @@ +package api + +import ( + "bytes" + "compress/gzip" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/user" + "path/filepath" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +func TestGetFilesContentDisposition(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + tests := []struct { + name string + filename string + expectedHeader string + }{ + { + name: "simple filename", + filename: "test.txt", + expectedHeader: `inline; filename=test.txt`, + }, + { + name: "filename with extension", + filename: "presentation.pptx", + expectedHeader: `inline; filename=presentation.pptx`, + }, + { + name: "filename with multiple dots", + filename: "archive.tar.gz", + expectedHeader: `inline; filename=archive.tar.gz`, + }, + { + name: "filename with spaces", + filename: "my document.pdf", + expectedHeader: `inline; filename="my document.pdf"`, + }, + { + name: "filename with quotes", + filename: `file"name.txt`, + expectedHeader: `inline; filename="file\"name.txt"`, + }, + { + name: "filename with backslash", + filename: `file\name.txt`, + expectedHeader: `inline; filename="file\\name.txt"`, + }, + { + name: "unicode filename", + filename: "\u6587\u6863.pdf", // 文档.pdf in Chinese + expectedHeader: "inline; filename*=utf-8''%E6%96%87%E6%A1%A3.pdf", + }, + { + name: "dotfile preserved", + filename: ".env", + expectedHeader: `inline; filename=.env`, + }, + { + name: "dotfile with extension preserved", + filename: ".gitignore", + expectedHeader: `inline; filename=.gitignore`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create a temp directory and file + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, tt.filename) + err := os.WriteFile(tempFile, []byte("test content"), 0o644) + require.NoError(t, err) + + // Create test API + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + api := New(&logger, defaults, nil, false) + + // Create request and response recorder + req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil) + w := httptest.NewRecorder() + + // Call the handler + params := GetFilesParams{ + Path: &tempFile, + Username: ¤tUser.Username, + } + api.GetFiles(w, req, params) + + // Check response + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify Content-Disposition header + contentDisposition := resp.Header.Get("Content-Disposition") + assert.Equal(t, tt.expectedHeader, contentDisposition, "Content-Disposition header should be set with correct filename") + }) + } +} + +func TestGetFilesContentDispositionWithNestedPath(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + // Create a temp directory with nested structure + tempDir := t.TempDir() + nestedDir := filepath.Join(tempDir, "subdir", "another") + err = os.MkdirAll(nestedDir, 0o755) + require.NoError(t, err) + + filename := "document.pdf" + tempFile := filepath.Join(nestedDir, filename) + err = os.WriteFile(tempFile, []byte("test content"), 0o644) + require.NoError(t, err) + + // Create test API + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + api := New(&logger, defaults, nil, false) + + // Create request and response recorder + req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil) + w := httptest.NewRecorder() + + // Call the handler + params := GetFilesParams{ + Path: &tempFile, + Username: ¤tUser.Username, + } + api.GetFiles(w, req, params) + + // Check response + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify Content-Disposition header uses only the base filename, not the full path + contentDisposition := resp.Header.Get("Content-Disposition") + assert.Equal(t, `inline; filename=document.pdf`, contentDisposition, "Content-Disposition should contain only the filename, not the path") +} + +func TestGetFiles_GzipEncoding_ExplicitIdentityOffWithRange(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + // Create a temp directory with a test file + tempDir := t.TempDir() + filename := "document.pdf" + tempFile := filepath.Join(tempDir, filename) + err = os.WriteFile(tempFile, []byte("test content"), 0o644) + require.NoError(t, err) + + // Create test API + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + api := New(&logger, defaults, nil, false) + + // Create request and response recorder + req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil) + req.Header.Set("Accept-Encoding", "gzip; q=1,*; q=0") + req.Header.Set("Range", "bytes=0-4") // Request first 5 bytes + w := httptest.NewRecorder() + + // Call the handler + params := GetFilesParams{ + Path: &tempFile, + Username: ¤tUser.Username, + } + api.GetFiles(w, req, params) + + // Check response + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode) +} + +func TestGetFiles_GzipDownload(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + originalContent := []byte("hello world, this is a test file for gzip compression") + + // Create a temp file with known content + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "test.txt") + err = os.WriteFile(tempFile, originalContent, 0o644) + require.NoError(t, err) + + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + api := New(&logger, defaults, nil, false) + + req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil) + req.Header.Set("Accept-Encoding", "gzip") + w := httptest.NewRecorder() + + params := GetFilesParams{ + Path: &tempFile, + Username: ¤tUser.Username, + } + api.GetFiles(w, req, params) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "gzip", resp.Header.Get("Content-Encoding")) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) + + // Decompress the gzip response body + gzReader, err := gzip.NewReader(resp.Body) + require.NoError(t, err) + defer gzReader.Close() + + decompressed, err := io.ReadAll(gzReader) + require.NoError(t, err) + + assert.Equal(t, originalContent, decompressed) +} + +func TestPostFiles_GzipUpload(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + originalContent := []byte("hello world, this is a test file uploaded with gzip") + + // Build a multipart body + var multipartBuf bytes.Buffer + mpWriter := multipart.NewWriter(&multipartBuf) + part, err := mpWriter.CreateFormFile("file", "uploaded.txt") + require.NoError(t, err) + _, err = part.Write(originalContent) + require.NoError(t, err) + err = mpWriter.Close() + require.NoError(t, err) + + // Gzip-compress the entire multipart body + var gzBuf bytes.Buffer + gzWriter := gzip.NewWriter(&gzBuf) + _, err = gzWriter.Write(multipartBuf.Bytes()) + require.NoError(t, err) + err = gzWriter.Close() + require.NoError(t, err) + + // Create test API + tempDir := t.TempDir() + destPath := filepath.Join(tempDir, "uploaded.txt") + + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + api := New(&logger, defaults, nil, false) + + req := httptest.NewRequest(http.MethodPost, "/files?path="+url.QueryEscape(destPath), &gzBuf) + req.Header.Set("Content-Type", mpWriter.FormDataContentType()) + req.Header.Set("Content-Encoding", "gzip") + w := httptest.NewRecorder() + + params := PostFilesParams{ + Path: &destPath, + Username: ¤tUser.Username, + } + api.PostFiles(w, req, params) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify the file was written with the original (decompressed) content + data, err := os.ReadFile(destPath) + require.NoError(t, err) + assert.Equal(t, originalContent, data) +} + +func TestGzipUploadThenGzipDownload(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + originalContent := []byte("round-trip gzip test: upload compressed, download compressed, verify match") + + // --- Upload with gzip --- + + // Build a multipart body + var multipartBuf bytes.Buffer + mpWriter := multipart.NewWriter(&multipartBuf) + part, err := mpWriter.CreateFormFile("file", "roundtrip.txt") + require.NoError(t, err) + _, err = part.Write(originalContent) + require.NoError(t, err) + err = mpWriter.Close() + require.NoError(t, err) + + // Gzip-compress the entire multipart body + var gzBuf bytes.Buffer + gzWriter := gzip.NewWriter(&gzBuf) + _, err = gzWriter.Write(multipartBuf.Bytes()) + require.NoError(t, err) + err = gzWriter.Close() + require.NoError(t, err) + + tempDir := t.TempDir() + destPath := filepath.Join(tempDir, "roundtrip.txt") + + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + api := New(&logger, defaults, nil, false) + + uploadReq := httptest.NewRequest(http.MethodPost, "/files?path="+url.QueryEscape(destPath), &gzBuf) + uploadReq.Header.Set("Content-Type", mpWriter.FormDataContentType()) + uploadReq.Header.Set("Content-Encoding", "gzip") + uploadW := httptest.NewRecorder() + + uploadParams := PostFilesParams{ + Path: &destPath, + Username: ¤tUser.Username, + } + api.PostFiles(uploadW, uploadReq, uploadParams) + + uploadResp := uploadW.Result() + defer uploadResp.Body.Close() + + require.Equal(t, http.StatusOK, uploadResp.StatusCode) + + // --- Download with gzip --- + + downloadReq := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(destPath), nil) + downloadReq.Header.Set("Accept-Encoding", "gzip") + downloadW := httptest.NewRecorder() + + downloadParams := GetFilesParams{ + Path: &destPath, + Username: ¤tUser.Username, + } + api.GetFiles(downloadW, downloadReq, downloadParams) + + downloadResp := downloadW.Result() + defer downloadResp.Body.Close() + + require.Equal(t, http.StatusOK, downloadResp.StatusCode) + assert.Equal(t, "gzip", downloadResp.Header.Get("Content-Encoding")) + + // Decompress and verify content matches original + gzReader, err := gzip.NewReader(downloadResp.Body) + require.NoError(t, err) + defer gzReader.Close() + + decompressed, err := io.ReadAll(gzReader) + require.NoError(t, err) + + assert.Equal(t, originalContent, decompressed) +} diff --git a/envd/internal/api/encoding.go b/envd/internal/api/encoding.go new file mode 100644 index 0000000..8ccf2fb --- /dev/null +++ b/envd/internal/api/encoding.go @@ -0,0 +1,227 @@ +package api + +import ( + "compress/gzip" + "fmt" + "io" + "net/http" + "slices" + "sort" + "strconv" + "strings" +) + +const ( + // EncodingGzip is the gzip content encoding. + EncodingGzip = "gzip" + // EncodingIdentity means no encoding (passthrough). + EncodingIdentity = "identity" + // EncodingWildcard means any encoding is acceptable. + EncodingWildcard = "*" +) + +// SupportedEncodings lists the content encodings supported for file transfer. +// The order matters - encodings are checked in order of preference. +var SupportedEncodings = []string{ + EncodingGzip, +} + +// encodingWithQuality holds an encoding name and its quality value. +type encodingWithQuality struct { + encoding string + quality float64 +} + +// isSupportedEncoding checks if the given encoding is in the supported list. +// Per RFC 7231, content-coding values are case-insensitive. +func isSupportedEncoding(encoding string) bool { + return slices.Contains(SupportedEncodings, strings.ToLower(encoding)) +} + +// parseEncodingWithQuality parses an encoding value and extracts the quality. +// Returns the encoding name (lowercased) and quality value (default 1.0 if not specified). +// Per RFC 7231, content-coding values are case-insensitive. +func parseEncodingWithQuality(value string) encodingWithQuality { + value = strings.TrimSpace(value) + quality := 1.0 + + if idx := strings.Index(value, ";"); idx != -1 { + params := value[idx+1:] + value = strings.TrimSpace(value[:idx]) + + // Parse q=X.X parameter + for param := range strings.SplitSeq(params, ";") { + param = strings.TrimSpace(param) + if strings.HasPrefix(strings.ToLower(param), "q=") { + if q, err := strconv.ParseFloat(param[2:], 64); err == nil { + quality = q + } + } + } + } + + // Normalize encoding to lowercase per RFC 7231 + return encodingWithQuality{encoding: strings.ToLower(value), quality: quality} +} + +// parseEncoding extracts the encoding name from a header value, stripping quality. +func parseEncoding(value string) string { + return parseEncodingWithQuality(value).encoding +} + +// parseContentEncoding parses the Content-Encoding header and returns the encoding. +// Returns an error if an unsupported encoding is specified. +// If no Content-Encoding header is present, returns empty string. +func parseContentEncoding(r *http.Request) (string, error) { + header := r.Header.Get("Content-Encoding") + if header == "" { + return EncodingIdentity, nil + } + + encoding := parseEncoding(header) + + if encoding == EncodingIdentity { + return EncodingIdentity, nil + } + + if !isSupportedEncoding(encoding) { + return "", fmt.Errorf("unsupported Content-Encoding: %s, supported: %v", header, SupportedEncodings) + } + + return encoding, nil +} + +// parseAcceptEncodingHeader parses the Accept-Encoding header and returns +// the parsed encodings along with the identity rejection state. +// Per RFC 7231 Section 5.3.4, identity is acceptable unless excluded by +// "identity;q=0" or "*;q=0" without a more specific entry for identity with q>0. +func parseAcceptEncodingHeader(header string) ([]encodingWithQuality, bool) { + if header == "" { + return nil, false // identity not rejected when header is empty + } + + // Parse all encodings with their quality values + var encodings []encodingWithQuality + for value := range strings.SplitSeq(header, ",") { + eq := parseEncodingWithQuality(value) + encodings = append(encodings, eq) + } + + // Check if identity is rejected per RFC 7231 Section 5.3.4: + // identity is acceptable unless excluded by "identity;q=0" or "*;q=0" + // without a more specific entry for identity with q>0. + identityRejected := false + identityExplicitlyAccepted := false + wildcardRejected := false + + for _, eq := range encodings { + switch eq.encoding { + case EncodingIdentity: + if eq.quality == 0 { + identityRejected = true + } else { + identityExplicitlyAccepted = true + } + case EncodingWildcard: + if eq.quality == 0 { + wildcardRejected = true + } + } + } + + if wildcardRejected && !identityExplicitlyAccepted { + identityRejected = true + } + + return encodings, identityRejected +} + +// isIdentityAcceptable checks if identity encoding is acceptable based on the +// Accept-Encoding header. Per RFC 7231 section 5.3.4, identity is always +// implicitly acceptable unless explicitly rejected with q=0. +func isIdentityAcceptable(r *http.Request) bool { + header := r.Header.Get("Accept-Encoding") + _, identityRejected := parseAcceptEncodingHeader(header) + + return !identityRejected +} + +// parseAcceptEncoding parses the Accept-Encoding header and returns the best +// supported encoding based on quality values. Per RFC 7231 section 5.3.4, +// identity is always implicitly acceptable unless explicitly rejected with q=0. +// If no Accept-Encoding header is present, returns empty string (identity). +func parseAcceptEncoding(r *http.Request) (string, error) { + header := r.Header.Get("Accept-Encoding") + if header == "" { + return EncodingIdentity, nil + } + + encodings, identityRejected := parseAcceptEncodingHeader(header) + + // Sort by quality value (highest first) + sort.Slice(encodings, func(i, j int) bool { + return encodings[i].quality > encodings[j].quality + }) + + // Find the best supported encoding + for _, eq := range encodings { + // Skip encodings with q=0 (explicitly rejected) + if eq.quality == 0 { + continue + } + + if eq.encoding == EncodingIdentity { + return EncodingIdentity, nil + } + + // Wildcard means any encoding is acceptable - return a supported encoding if identity is rejected + if eq.encoding == EncodingWildcard { + if identityRejected && len(SupportedEncodings) > 0 { + return SupportedEncodings[0], nil + } + + return EncodingIdentity, nil + } + + if isSupportedEncoding(eq.encoding) { + return eq.encoding, nil + } + } + + // Per RFC 7231, identity is implicitly acceptable unless rejected + if !identityRejected { + return EncodingIdentity, nil + } + + // Identity rejected and no supported encodings found + return "", fmt.Errorf("no acceptable encoding found, supported: %v", SupportedEncodings) +} + +// getDecompressedBody returns a reader that decompresses the request body based on +// Content-Encoding header. Returns the original body if no encoding is specified. +// Returns an error if an unsupported encoding is specified. +// The caller is responsible for closing both the returned ReadCloser and the +// original request body (r.Body) separately. +func getDecompressedBody(r *http.Request) (io.ReadCloser, error) { + encoding, err := parseContentEncoding(r) + if err != nil { + return nil, err + } + + if encoding == EncodingIdentity { + return r.Body, nil + } + + switch encoding { + case EncodingGzip: + gzReader, err := gzip.NewReader(r.Body) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %w", err) + } + + return gzReader, nil + default: + // This shouldn't happen if isSupportedEncoding is correct + return nil, fmt.Errorf("encoding %s is supported but not implemented", encoding) + } +} diff --git a/envd/internal/api/encoding_test.go b/envd/internal/api/encoding_test.go new file mode 100644 index 0000000..1a639e7 --- /dev/null +++ b/envd/internal/api/encoding_test.go @@ -0,0 +1,494 @@ +package api + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsSupportedEncoding(t *testing.T) { + t.Parallel() + + t.Run("gzip is supported", func(t *testing.T) { + t.Parallel() + assert.True(t, isSupportedEncoding("gzip")) + }) + + t.Run("GZIP is supported (case-insensitive)", func(t *testing.T) { + t.Parallel() + assert.True(t, isSupportedEncoding("GZIP")) + }) + + t.Run("Gzip is supported (case-insensitive)", func(t *testing.T) { + t.Parallel() + assert.True(t, isSupportedEncoding("Gzip")) + }) + + t.Run("br is not supported", func(t *testing.T) { + t.Parallel() + assert.False(t, isSupportedEncoding("br")) + }) + + t.Run("deflate is not supported", func(t *testing.T) { + t.Parallel() + assert.False(t, isSupportedEncoding("deflate")) + }) +} + +func TestParseEncodingWithQuality(t *testing.T) { + t.Parallel() + + t.Run("returns encoding with default quality 1.0", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("gzip") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 1.0, eq.quality, 0.001) + }) + + t.Run("parses quality value", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("gzip;q=0.5") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 0.5, eq.quality, 0.001) + }) + + t.Run("parses quality value with whitespace", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("gzip ; q=0.8") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 0.8, eq.quality, 0.001) + }) + + t.Run("handles q=0", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("gzip;q=0") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 0.0, eq.quality, 0.001) + }) + + t.Run("handles invalid quality value", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("gzip;q=invalid") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 1.0, eq.quality, 0.001) // defaults to 1.0 on parse error + }) + + t.Run("trims whitespace from encoding", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality(" gzip ") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 1.0, eq.quality, 0.001) + }) + + t.Run("normalizes encoding to lowercase", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("GZIP") + assert.Equal(t, "gzip", eq.encoding) + }) + + t.Run("normalizes mixed case encoding", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("Gzip;q=0.5") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 0.5, eq.quality, 0.001) + }) +} + +func TestParseEncoding(t *testing.T) { + t.Parallel() + + t.Run("returns encoding as-is", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "gzip", parseEncoding("gzip")) + }) + + t.Run("trims whitespace", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "gzip", parseEncoding(" gzip ")) + }) + + t.Run("strips quality value", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "gzip", parseEncoding("gzip;q=1.0")) + }) + + t.Run("strips quality value with whitespace", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "gzip", parseEncoding("gzip ; q=0.5")) + }) +} + +func TestParseContentEncoding(t *testing.T) { + t.Parallel() + + t.Run("returns identity when no header", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + + encoding, err := parseContentEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("returns gzip when Content-Encoding is gzip", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + req.Header.Set("Content-Encoding", "gzip") + + encoding, err := parseContentEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns gzip when Content-Encoding is GZIP (case-insensitive)", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + req.Header.Set("Content-Encoding", "GZIP") + + encoding, err := parseContentEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns gzip when Content-Encoding is Gzip (case-insensitive)", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + req.Header.Set("Content-Encoding", "Gzip") + + encoding, err := parseContentEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns identity for identity encoding", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + req.Header.Set("Content-Encoding", "identity") + + encoding, err := parseContentEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("returns error for unsupported encoding", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + req.Header.Set("Content-Encoding", "br") + + _, err := parseContentEncoding(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported Content-Encoding") + assert.Contains(t, err.Error(), "supported: [gzip]") + }) + + t.Run("handles gzip with quality value", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + req.Header.Set("Content-Encoding", "gzip;q=1.0") + + encoding, err := parseContentEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) +} + +func TestParseAcceptEncoding(t *testing.T) { + t.Parallel() + + t.Run("returns identity when no header", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("returns gzip when Accept-Encoding is gzip", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "gzip") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns gzip when Accept-Encoding is GZIP (case-insensitive)", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "GZIP") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns gzip when gzip is among multiple encodings", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "deflate, gzip, br") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns gzip with quality value", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "gzip;q=1.0") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns identity for identity encoding", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "identity") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("returns identity for wildcard encoding", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "*") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("falls back to identity for unsupported encoding only", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "br") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("falls back to identity when only unsupported encodings", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "deflate, br") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("selects gzip when it has highest quality", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "br;q=0.5, gzip;q=1.0, deflate;q=0.8") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("selects gzip even with lower quality when others unsupported", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "br;q=1.0, gzip;q=0.5") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns identity when it has higher quality than gzip", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "gzip;q=0.5, identity;q=1.0") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("skips encoding with q=0", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "gzip;q=0, identity") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("falls back to identity when gzip rejected and no other supported", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "gzip;q=0, br") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("returns error when identity explicitly rejected and no supported encoding", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "br, identity;q=0") + + _, err := parseAcceptEncoding(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "no acceptable encoding found") + }) + + t.Run("returns gzip for wildcard when identity rejected", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "*, identity;q=0") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) // wildcard with identity rejected returns supported encoding + }) + + t.Run("returns error when wildcard rejected and no explicit identity", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "*;q=0") + + _, err := parseAcceptEncoding(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "no acceptable encoding found") + }) + + t.Run("returns identity when wildcard rejected but identity explicitly accepted", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "*;q=0, identity") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("returns gzip when wildcard rejected but gzip explicitly accepted", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "*;q=0, gzip") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingGzip, encoding) + }) +} + +func TestGetDecompressedBody(t *testing.T) { + t.Parallel() + + t.Run("returns original body when no Content-Encoding header", func(t *testing.T) { + t.Parallel() + content := []byte("test content") + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", bytes.NewReader(content)) + + body, err := getDecompressedBody(req) + require.NoError(t, err) + assert.Equal(t, req.Body, body, "should return original body") + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, content, data) + }) + + t.Run("decompresses gzip body when Content-Encoding is gzip", func(t *testing.T) { + t.Parallel() + originalContent := []byte("test content to compress") + + var compressed bytes.Buffer + gw := gzip.NewWriter(&compressed) + _, err := gw.Write(originalContent) + require.NoError(t, err) + err = gw.Close() + require.NoError(t, err) + + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", bytes.NewReader(compressed.Bytes())) + req.Header.Set("Content-Encoding", "gzip") + + body, err := getDecompressedBody(req) + require.NoError(t, err) + defer body.Close() + + assert.NotEqual(t, req.Body, body, "should return a new gzip reader") + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, originalContent, data) + }) + + t.Run("returns error for invalid gzip data", func(t *testing.T) { + t.Parallel() + invalidGzip := []byte("this is not gzip data") + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", bytes.NewReader(invalidGzip)) + req.Header.Set("Content-Encoding", "gzip") + + _, err := getDecompressedBody(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create gzip reader") + }) + + t.Run("returns original body for identity encoding", func(t *testing.T) { + t.Parallel() + content := []byte("test content") + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", bytes.NewReader(content)) + req.Header.Set("Content-Encoding", "identity") + + body, err := getDecompressedBody(req) + require.NoError(t, err) + assert.Equal(t, req.Body, body, "should return original body") + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, content, data) + }) + + t.Run("returns error for unsupported encoding", func(t *testing.T) { + t.Parallel() + content := []byte("test content") + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", bytes.NewReader(content)) + req.Header.Set("Content-Encoding", "br") + + _, err := getDecompressedBody(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported Content-Encoding") + }) + + t.Run("handles gzip with quality value", func(t *testing.T) { + t.Parallel() + originalContent := []byte("test content to compress") + + var compressed bytes.Buffer + gw := gzip.NewWriter(&compressed) + _, err := gw.Write(originalContent) + require.NoError(t, err) + err = gw.Close() + require.NoError(t, err) + + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", bytes.NewReader(compressed.Bytes())) + req.Header.Set("Content-Encoding", "gzip;q=1.0") + + body, err := getDecompressedBody(req) + require.NoError(t, err) + defer body.Close() + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, originalContent, data) + }) +} diff --git a/envd/internal/api/envs.go b/envd/internal/api/envs.go new file mode 100644 index 0000000..4dcc0cd --- /dev/null +++ b/envd/internal/api/envs.go @@ -0,0 +1,29 @@ +package api + +import ( + "encoding/json" + "net/http" + + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" +) + +func (a *API) GetEnvs(w http.ResponseWriter, _ *http.Request) { + operationID := logs.AssignOperationID() + + a.logger.Debug().Str(string(logs.OperationIDKey), operationID).Msg("Getting env vars") + + envs := make(EnvVars) + a.defaults.EnvVars.Range(func(key, value string) bool { + envs[key] = value + + return true + }) + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(envs); err != nil { + a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("Failed to encode env vars") + } +} diff --git a/envd/internal/api/error.go b/envd/internal/api/error.go new file mode 100644 index 0000000..2634d03 --- /dev/null +++ b/envd/internal/api/error.go @@ -0,0 +1,21 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" +) + +func jsonError(w http.ResponseWriter, code int, err error) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + + w.WriteHeader(code) + encodeErr := json.NewEncoder(w).Encode(Error{ + Code: code, + Message: err.Error(), + }) + if encodeErr != nil { + http.Error(w, errors.Join(encodeErr, err).Error(), http.StatusInternalServerError) + } +} diff --git a/envd/internal/api/generate.go b/envd/internal/api/generate.go new file mode 100644 index 0000000..40fc05e --- /dev/null +++ b/envd/internal/api/generate.go @@ -0,0 +1,3 @@ +package api + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config cfg.yaml ../../spec/envd.yaml diff --git a/envd/internal/api/init.go b/envd/internal/api/init.go new file mode 100644 index 0000000..dd55c5c --- /dev/null +++ b/envd/internal/api/init.go @@ -0,0 +1,314 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/netip" + "os/exec" + "time" + + "github.com/awnumar/memguard" + "github.com/rs/zerolog" + "github.com/txn2/txeh" + "golang.org/x/sys/unix" + + "git.omukk.dev/wrenn/sandbox/envd/internal/host" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/keys" +) + +var ( + ErrAccessTokenMismatch = errors.New("access token validation failed") + ErrAccessTokenResetNotAuthorized = errors.New("access token reset not authorized") +) + +const ( + maxTimeInPast = 50 * time.Millisecond + maxTimeInFuture = 5 * time.Second +) + +// validateInitAccessToken validates the access token for /init requests. +// Token is valid if it matches the existing token OR the MMDS hash. +// If neither exists, first-time setup is allowed. +func (a *API) validateInitAccessToken(ctx context.Context, requestToken *SecureToken) error { + requestTokenSet := requestToken.IsSet() + + // Fast path: token matches existing + if a.accessToken.IsSet() && requestTokenSet && a.accessToken.EqualsSecure(requestToken) { + return nil + } + + // Check MMDS only if token didn't match existing + matchesMMDS, mmdsExists := a.checkMMDSHash(ctx, requestToken) + + switch { + case matchesMMDS: + return nil + case !a.accessToken.IsSet() && !mmdsExists: + return nil // first-time setup + case !requestTokenSet: + return ErrAccessTokenResetNotAuthorized + default: + return ErrAccessTokenMismatch + } +} + +// checkMMDSHash checks if the request token matches the MMDS hash. +// Returns (matches, mmdsExists). +// +// The MMDS hash is set by the orchestrator during Resume: +// - hash(token): requires this specific token +// - hash(""): explicitly allows nil token (token reset authorized) +// - "": MMDS not properly configured, no authorization granted +func (a *API) checkMMDSHash(ctx context.Context, requestToken *SecureToken) (bool, bool) { + if a.isNotFC { + return false, false + } + + mmdsHash, err := a.mmdsClient.GetAccessTokenHash(ctx) + if err != nil { + return false, false + } + + if mmdsHash == "" { + return false, false + } + + if !requestToken.IsSet() { + return mmdsHash == keys.HashAccessToken(""), true + } + + tokenBytes, err := requestToken.Bytes() + if err != nil { + return false, true + } + defer memguard.WipeBytes(tokenBytes) + + return keys.HashAccessTokenBytes(tokenBytes) == mmdsHash, true +} + +func (a *API) PostInit(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + ctx := r.Context() + + operationID := logs.AssignOperationID() + logger := a.logger.With().Str(string(logs.OperationIDKey), operationID).Logger() + + if r.Body != nil { + // Read raw body so we can wipe it after parsing + body, err := io.ReadAll(r.Body) + // Ensure body is wiped after we're done + defer memguard.WipeBytes(body) + if err != nil { + logger.Error().Msgf("Failed to read request body: %v", err) + w.WriteHeader(http.StatusBadRequest) + + return + } + + var initRequest PostInitJSONBody + if len(body) > 0 { + err = json.Unmarshal(body, &initRequest) + if err != nil { + logger.Error().Msgf("Failed to decode request: %v", err) + w.WriteHeader(http.StatusBadRequest) + + return + } + } + + // Ensure request token is destroyed if not transferred via TakeFrom. + // This handles: validation failures, timestamp-based skips, and any early returns. + // Safe because Destroy() is nil-safe and TakeFrom clears the source. + defer initRequest.AccessToken.Destroy() + + a.initLock.Lock() + defer a.initLock.Unlock() + + // Update data only if the request is newer or if there's no timestamp at all + if initRequest.Timestamp == nil || a.lastSetTime.SetToGreater(initRequest.Timestamp.UnixNano()) { + err = a.SetData(ctx, logger, initRequest) + if err != nil { + switch { + case errors.Is(err, ErrAccessTokenMismatch), errors.Is(err, ErrAccessTokenResetNotAuthorized): + w.WriteHeader(http.StatusUnauthorized) + default: + logger.Error().Msgf("Failed to set data: %v", err) + w.WriteHeader(http.StatusBadRequest) + } + w.Write([]byte(err.Error())) + + return + } + } + } + + go func() { //nolint:contextcheck // TODO: fix this later + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + host.PollForMMDSOpts(ctx, a.mmdsChan, a.defaults.EnvVars) + }() + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "") + + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) SetData(ctx context.Context, logger zerolog.Logger, data PostInitJSONBody) error { + // Validate access token before proceeding with any action + // The request must provide a token that is either: + // 1. Matches the existing access token (if set), OR + // 2. Matches the MMDS hash (for token change during resume) + if err := a.validateInitAccessToken(ctx, data.AccessToken); err != nil { + return err + } + + if data.Timestamp != nil { + // Check if current time differs significantly from the received timestamp + if shouldSetSystemTime(time.Now(), *data.Timestamp) { + logger.Debug().Msgf("Setting sandbox start time to: %v", *data.Timestamp) + ts := unix.NsecToTimespec(data.Timestamp.UnixNano()) + err := unix.ClockSettime(unix.CLOCK_REALTIME, &ts) + if err != nil { + logger.Error().Msgf("Failed to set system time: %v", err) + } + } else { + logger.Debug().Msgf("Current time is within acceptable range of timestamp %v, not setting system time", *data.Timestamp) + } + } + + if data.EnvVars != nil { + logger.Debug().Msg(fmt.Sprintf("Setting %d env vars", len(*data.EnvVars))) + + for key, value := range *data.EnvVars { + logger.Debug().Msgf("Setting env var for %s", key) + a.defaults.EnvVars.Store(key, value) + } + } + + if data.AccessToken.IsSet() { + logger.Debug().Msg("Setting access token") + a.accessToken.TakeFrom(data.AccessToken) + } else if a.accessToken.IsSet() { + logger.Debug().Msg("Clearing access token") + a.accessToken.Destroy() + } + + if data.HyperloopIP != nil { + go a.SetupHyperloop(*data.HyperloopIP) + } + + if data.DefaultUser != nil && *data.DefaultUser != "" { + logger.Debug().Msgf("Setting default user to: %s", *data.DefaultUser) + a.defaults.User = *data.DefaultUser + } + + if data.DefaultWorkdir != nil && *data.DefaultWorkdir != "" { + logger.Debug().Msgf("Setting default workdir to: %s", *data.DefaultWorkdir) + a.defaults.Workdir = data.DefaultWorkdir + } + + if data.VolumeMounts != nil { + for _, volume := range *data.VolumeMounts { + logger.Debug().Msgf("Mounting %s at %q", volume.NfsTarget, volume.Path) + + go a.setupNfs(context.WithoutCancel(ctx), volume.NfsTarget, volume.Path) + } + } + + return nil +} + +func (a *API) setupNfs(ctx context.Context, nfsTarget, path string) { + commands := [][]string{ + {"mkdir", "-p", path}, + {"mount", "-v", "-t", "nfs", "-o", "mountproto=tcp,mountport=2049,proto=tcp,port=2049,nfsvers=3,noacl", nfsTarget, path}, + } + + for _, command := range commands { + data, err := exec.CommandContext(ctx, command[0], command[1:]...).CombinedOutput() + + logger := a.getLogger(err) + + logger. + Strs("command", command). + Str("output", string(data)). + Msg("Mount NFS") + + if err != nil { + return + } + } +} + +func (a *API) SetupHyperloop(address string) { + a.hyperloopLock.Lock() + defer a.hyperloopLock.Unlock() + + if err := rewriteHostsFile(address, "/etc/hosts"); err != nil { + a.logger.Error().Err(err).Msg("failed to modify hosts file") + } else { + a.defaults.EnvVars.Store("WRENN_EVENTS_ADDRESS", fmt.Sprintf("http://%s", address)) + } +} + +const eventsHost = "events.wrenn.local" + +func rewriteHostsFile(address, path string) error { + hosts, err := txeh.NewHosts(&txeh.HostsConfig{ + ReadFilePath: path, + WriteFilePath: path, + }) + if err != nil { + return fmt.Errorf("failed to create hosts: %w", err) + } + + // Update /etc/hosts to point events.wrenn.local to the hyperloop IP + // This will remove any existing entries for events.wrenn.local first + ipFamily, err := getIPFamily(address) + if err != nil { + return fmt.Errorf("failed to get ip family: %w", err) + } + + if ok, current, _ := hosts.HostAddressLookup(eventsHost, ipFamily); ok && current == address { + return nil // nothing to be done + } + + hosts.AddHost(address, eventsHost) + + return hosts.Save() +} + +var ( + ErrInvalidAddress = errors.New("invalid IP address") + ErrUnknownAddressFormat = errors.New("unknown IP address format") +) + +func getIPFamily(address string) (txeh.IPFamily, error) { + addressIP, err := netip.ParseAddr(address) + if err != nil { + return txeh.IPFamilyV4, fmt.Errorf("failed to parse IP address: %w", err) + } + + switch { + case addressIP.Is4(): + return txeh.IPFamilyV4, nil + case addressIP.Is6(): + return txeh.IPFamilyV6, nil + default: + return txeh.IPFamilyV4, fmt.Errorf("%w: %s", ErrUnknownAddressFormat, address) + } +} + +// shouldSetSystemTime returns true if the current time differs significantly from the received timestamp, +// indicating the system clock should be adjusted. Returns true when the sandboxTime is more than +// maxTimeInPast before the hostTime or more than maxTimeInFuture after the hostTime. +func shouldSetSystemTime(sandboxTime, hostTime time.Time) bool { + return sandboxTime.Before(hostTime.Add(-maxTimeInPast)) || sandboxTime.After(hostTime.Add(maxTimeInFuture)) +} diff --git a/envd/internal/api/init_test.go b/envd/internal/api/init_test.go new file mode 100644 index 0000000..b44924c --- /dev/null +++ b/envd/internal/api/init_test.go @@ -0,0 +1,587 @@ +package api + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/keys" + utilsShared "git.omukk.dev/wrenn/sandbox/envd/internal/shared/utils" +) + +func TestSimpleCases(t *testing.T) { + t.Parallel() + testCases := map[string]func(string) string{ + "both newlines": func(s string) string { return s }, + "no newline prefix": func(s string) string { return strings.TrimPrefix(s, "\n") }, + "no newline suffix": func(s string) string { return strings.TrimSuffix(s, "\n") }, + "no newline prefix or suffix": strings.TrimSpace, + } + + for name, preprocessor := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + + value := ` +# comment +127.0.0.1 one.host +127.0.0.2 two.host +` + value = preprocessor(value) + inputPath := filepath.Join(tempDir, "hosts") + err := os.WriteFile(inputPath, []byte(value), 0o644) + require.NoError(t, err) + + err = rewriteHostsFile("127.0.0.3", inputPath) + require.NoError(t, err) + + data, err := os.ReadFile(inputPath) + require.NoError(t, err) + + assert.Equal(t, `# comment +127.0.0.1 one.host +127.0.0.2 two.host +127.0.0.3 events.wrenn.local`, strings.TrimSpace(string(data))) + }) + } +} + +func TestShouldSetSystemTime(t *testing.T) { + t.Parallel() + sandboxTime := time.Now() + + tests := []struct { + name string + hostTime time.Time + want bool + }{ + { + name: "sandbox time far ahead of host time (should set)", + hostTime: sandboxTime.Add(-10 * time.Second), + want: true, + }, + { + name: "sandbox time at maxTimeInPast boundary ahead of host time (should not set)", + hostTime: sandboxTime.Add(-50 * time.Millisecond), + want: false, + }, + { + name: "sandbox time just within maxTimeInPast ahead of host time (should not set)", + hostTime: sandboxTime.Add(-40 * time.Millisecond), + want: false, + }, + { + name: "sandbox time slightly ahead of host time (should not set)", + hostTime: sandboxTime.Add(-10 * time.Millisecond), + want: false, + }, + { + name: "sandbox time equals host time (should not set)", + hostTime: sandboxTime, + want: false, + }, + { + name: "sandbox time slightly behind host time (should not set)", + hostTime: sandboxTime.Add(1 * time.Second), + want: false, + }, + { + name: "sandbox time just within maxTimeInFuture behind host time (should not set)", + hostTime: sandboxTime.Add(4 * time.Second), + want: false, + }, + { + name: "sandbox time at maxTimeInFuture boundary behind host time (should not set)", + hostTime: sandboxTime.Add(5 * time.Second), + want: false, + }, + { + name: "sandbox time far behind host time (should set)", + hostTime: sandboxTime.Add(1 * time.Minute), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := shouldSetSystemTime(tt.hostTime, sandboxTime) + assert.Equal(t, tt.want, got) + }) + } +} + +func secureTokenPtr(s string) *SecureToken { + token := &SecureToken{} + _ = token.Set([]byte(s)) + + return token +} + +type mockMMDSClient struct { + hash string + err error +} + +func (m *mockMMDSClient) GetAccessTokenHash(_ context.Context) (string, error) { + return m.hash, m.err +} + +func newTestAPI(accessToken *SecureToken, mmdsClient MMDSClient) *API { + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + } + api := New(&logger, defaults, nil, false) + if accessToken != nil { + api.accessToken.TakeFrom(accessToken) + } + api.mmdsClient = mmdsClient + + return api +} + +func TestValidateInitAccessToken(t *testing.T) { + t.Parallel() + ctx := t.Context() + + tests := []struct { + name string + accessToken *SecureToken + requestToken *SecureToken + mmdsHash string + mmdsErr error + wantErr error + }{ + { + name: "fast path: token matches existing", + accessToken: secureTokenPtr("secret-token"), + requestToken: secureTokenPtr("secret-token"), + mmdsHash: "", + mmdsErr: nil, + wantErr: nil, + }, + { + name: "MMDS match: token hash matches MMDS hash", + accessToken: secureTokenPtr("old-token"), + requestToken: secureTokenPtr("new-token"), + mmdsHash: keys.HashAccessToken("new-token"), + mmdsErr: nil, + wantErr: nil, + }, + { + name: "first-time setup: no existing token, MMDS error", + accessToken: nil, + requestToken: secureTokenPtr("new-token"), + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: nil, + }, + { + name: "first-time setup: no existing token, empty MMDS hash", + accessToken: nil, + requestToken: secureTokenPtr("new-token"), + mmdsHash: "", + mmdsErr: nil, + wantErr: nil, + }, + { + name: "first-time setup: both tokens nil, no MMDS", + accessToken: nil, + requestToken: nil, + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: nil, + }, + { + name: "mismatch: existing token differs from request, no MMDS", + accessToken: secureTokenPtr("existing-token"), + requestToken: secureTokenPtr("wrong-token"), + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: ErrAccessTokenMismatch, + }, + { + name: "mismatch: existing token differs from request, MMDS hash mismatch", + accessToken: secureTokenPtr("existing-token"), + requestToken: secureTokenPtr("wrong-token"), + mmdsHash: keys.HashAccessToken("different-token"), + mmdsErr: nil, + wantErr: ErrAccessTokenMismatch, + }, + { + name: "conflict: existing token, nil request, MMDS exists", + accessToken: secureTokenPtr("existing-token"), + requestToken: nil, + mmdsHash: keys.HashAccessToken("some-token"), + mmdsErr: nil, + wantErr: ErrAccessTokenResetNotAuthorized, + }, + { + name: "conflict: existing token, nil request, no MMDS", + accessToken: secureTokenPtr("existing-token"), + requestToken: nil, + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: ErrAccessTokenResetNotAuthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: tt.mmdsHash, err: tt.mmdsErr} + api := newTestAPI(tt.accessToken, mmdsClient) + + err := api.validateInitAccessToken(ctx, tt.requestToken) + + if tt.wantErr != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCheckMMDSHash(t *testing.T) { + t.Parallel() + ctx := t.Context() + + t.Run("returns match when token hash equals MMDS hash", func(t *testing.T) { + t.Parallel() + token := "my-secret-token" + mmdsClient := &mockMMDSClient{hash: keys.HashAccessToken(token), err: nil} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, secureTokenPtr(token)) + + assert.True(t, matches) + assert.True(t, exists) + }) + + t.Run("returns no match when token hash differs from MMDS hash", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: keys.HashAccessToken("different-token"), err: nil} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, secureTokenPtr("my-token")) + + assert.False(t, matches) + assert.True(t, exists) + }) + + t.Run("returns exists but no match when request token is nil", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: keys.HashAccessToken("some-token"), err: nil} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, nil) + + assert.False(t, matches) + assert.True(t, exists) + }) + + t.Run("returns false, false when MMDS returns error", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, secureTokenPtr("any-token")) + + assert.False(t, matches) + assert.False(t, exists) + }) + + t.Run("returns false, false when MMDS returns empty hash with non-nil request", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: nil} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, secureTokenPtr("any-token")) + + assert.False(t, matches) + assert.False(t, exists) + }) + + t.Run("returns false, false when MMDS returns empty hash with nil request", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: nil} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, nil) + + assert.False(t, matches) + assert.False(t, exists) + }) + + t.Run("returns true, true when MMDS returns hash of empty string with nil request (explicit reset)", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: keys.HashAccessToken(""), err: nil} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, nil) + + assert.True(t, matches) + assert.True(t, exists) + }) +} + +func TestSetData(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := zerolog.Nop() + + t.Run("access token updates", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + existingToken *SecureToken + requestToken *SecureToken + mmdsHash string + mmdsErr error + wantErr error + wantFinalToken *SecureToken + }{ + { + name: "first-time setup: sets initial token", + existingToken: nil, + requestToken: secureTokenPtr("initial-token"), + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: nil, + wantFinalToken: secureTokenPtr("initial-token"), + }, + { + name: "first-time setup: nil request token leaves token unset", + existingToken: nil, + requestToken: nil, + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: nil, + wantFinalToken: nil, + }, + { + name: "re-init with same token: token unchanged", + existingToken: secureTokenPtr("same-token"), + requestToken: secureTokenPtr("same-token"), + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: nil, + wantFinalToken: secureTokenPtr("same-token"), + }, + { + name: "resume with MMDS: updates token when hash matches", + existingToken: secureTokenPtr("old-token"), + requestToken: secureTokenPtr("new-token"), + mmdsHash: keys.HashAccessToken("new-token"), + mmdsErr: nil, + wantErr: nil, + wantFinalToken: secureTokenPtr("new-token"), + }, + { + name: "resume with MMDS: fails when hash doesn't match", + existingToken: secureTokenPtr("old-token"), + requestToken: secureTokenPtr("new-token"), + mmdsHash: keys.HashAccessToken("different-token"), + mmdsErr: nil, + wantErr: ErrAccessTokenMismatch, + wantFinalToken: secureTokenPtr("old-token"), + }, + { + name: "fails when existing token and request token mismatch without MMDS", + existingToken: secureTokenPtr("existing-token"), + requestToken: secureTokenPtr("wrong-token"), + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: ErrAccessTokenMismatch, + wantFinalToken: secureTokenPtr("existing-token"), + }, + { + name: "conflict when existing token but nil request token", + existingToken: secureTokenPtr("existing-token"), + requestToken: nil, + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: ErrAccessTokenResetNotAuthorized, + wantFinalToken: secureTokenPtr("existing-token"), + }, + { + name: "conflict when existing token but nil request with MMDS present", + existingToken: secureTokenPtr("existing-token"), + requestToken: nil, + mmdsHash: keys.HashAccessToken("some-token"), + mmdsErr: nil, + wantErr: ErrAccessTokenResetNotAuthorized, + wantFinalToken: secureTokenPtr("existing-token"), + }, + { + name: "conflict when MMDS returns empty hash and request is nil (prevents unauthorized reset)", + existingToken: secureTokenPtr("existing-token"), + requestToken: nil, + mmdsHash: "", + mmdsErr: nil, + wantErr: ErrAccessTokenResetNotAuthorized, + wantFinalToken: secureTokenPtr("existing-token"), + }, + { + name: "resets token when MMDS returns hash of empty string and request is nil (explicit reset)", + existingToken: secureTokenPtr("existing-token"), + requestToken: nil, + mmdsHash: keys.HashAccessToken(""), + mmdsErr: nil, + wantErr: nil, + wantFinalToken: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: tt.mmdsHash, err: tt.mmdsErr} + api := newTestAPI(tt.existingToken, mmdsClient) + + data := PostInitJSONBody{ + AccessToken: tt.requestToken, + } + + err := api.SetData(ctx, logger, data) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + + if tt.wantFinalToken == nil { + assert.False(t, api.accessToken.IsSet(), "expected token to not be set") + } else { + require.True(t, api.accessToken.IsSet(), "expected token to be set") + assert.True(t, api.accessToken.EqualsSecure(tt.wantFinalToken), "expected token to match") + } + }) + } + }) + + t.Run("sets environment variables", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + + envVars := EnvVars{"FOO": "bar", "BAZ": "qux"} + data := PostInitJSONBody{ + EnvVars: &envVars, + } + + err := api.SetData(ctx, logger, data) + + require.NoError(t, err) + val, ok := api.defaults.EnvVars.Load("FOO") + assert.True(t, ok) + assert.Equal(t, "bar", val) + val, ok = api.defaults.EnvVars.Load("BAZ") + assert.True(t, ok) + assert.Equal(t, "qux", val) + }) + + t.Run("sets default user", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + + data := PostInitJSONBody{ + DefaultUser: utilsShared.ToPtr("testuser"), + } + + err := api.SetData(ctx, logger, data) + + require.NoError(t, err) + assert.Equal(t, "testuser", api.defaults.User) + }) + + t.Run("does not set default user when empty", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + api.defaults.User = "original" + + data := PostInitJSONBody{ + DefaultUser: utilsShared.ToPtr(""), + } + + err := api.SetData(ctx, logger, data) + + require.NoError(t, err) + assert.Equal(t, "original", api.defaults.User) + }) + + t.Run("sets default workdir", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + + data := PostInitJSONBody{ + DefaultWorkdir: utilsShared.ToPtr("/home/user"), + } + + err := api.SetData(ctx, logger, data) + + require.NoError(t, err) + require.NotNil(t, api.defaults.Workdir) + assert.Equal(t, "/home/user", *api.defaults.Workdir) + }) + + t.Run("does not set default workdir when empty", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + originalWorkdir := "/original" + api.defaults.Workdir = &originalWorkdir + + data := PostInitJSONBody{ + DefaultWorkdir: utilsShared.ToPtr(""), + } + + err := api.SetData(ctx, logger, data) + + require.NoError(t, err) + require.NotNil(t, api.defaults.Workdir) + assert.Equal(t, "/original", *api.defaults.Workdir) + }) + + t.Run("sets multiple fields at once", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + + envVars := EnvVars{"KEY": "value"} + data := PostInitJSONBody{ + AccessToken: secureTokenPtr("token"), + DefaultUser: utilsShared.ToPtr("user"), + DefaultWorkdir: utilsShared.ToPtr("/workdir"), + EnvVars: &envVars, + } + + err := api.SetData(ctx, logger, data) + + require.NoError(t, err) + assert.True(t, api.accessToken.Equals("token"), "expected token to match") + assert.Equal(t, "user", api.defaults.User) + assert.Equal(t, "/workdir", *api.defaults.Workdir) + val, ok := api.defaults.EnvVars.Load("KEY") + assert.True(t, ok) + assert.Equal(t, "value", val) + }) +} diff --git a/envd/internal/api/secure_token.go b/envd/internal/api/secure_token.go new file mode 100644 index 0000000..fee152a --- /dev/null +++ b/envd/internal/api/secure_token.go @@ -0,0 +1,212 @@ +package api + +import ( + "bytes" + "errors" + "sync" + + "github.com/awnumar/memguard" +) + +var ( + ErrTokenNotSet = errors.New("access token not set") + ErrTokenEmpty = errors.New("empty token not allowed") +) + +// SecureToken wraps memguard for secure token storage. +// It uses LockedBuffer which provides memory locking, guard pages, +// and secure zeroing on destroy. +type SecureToken struct { + mu sync.RWMutex + buffer *memguard.LockedBuffer +} + +// Set securely replaces the token, destroying the old one first. +// The old token memory is zeroed before the new token is stored. +// The input byte slice is wiped after copying to secure memory. +// Returns ErrTokenEmpty if token is empty - use Destroy() to clear the token instead. +func (s *SecureToken) Set(token []byte) error { + if len(token) == 0 { + return ErrTokenEmpty + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Destroy old token first (zeros memory) + if s.buffer != nil { + s.buffer.Destroy() + s.buffer = nil + } + + // Create new LockedBuffer from bytes (source slice is wiped by memguard) + s.buffer = memguard.NewBufferFromBytes(token) + + return nil +} + +// UnmarshalJSON implements json.Unmarshaler to securely parse a JSON string +// directly into memguard, wiping the input bytes after copying. +// +// Access tokens are hex-encoded HMAC-SHA256 hashes (64 chars of [0-9a-f]), +// so they never contain JSON escape sequences. +func (s *SecureToken) UnmarshalJSON(data []byte) error { + // JSON strings are quoted, so minimum valid is `""` (2 bytes). + if len(data) < 2 || data[0] != '"' || data[len(data)-1] != '"' { + memguard.WipeBytes(data) + + return errors.New("invalid secure token JSON string") + } + + content := data[1 : len(data)-1] + + // Access tokens are hex strings - reject if contains backslash + if bytes.ContainsRune(content, '\\') { + memguard.WipeBytes(data) + + return errors.New("invalid secure token: unexpected escape sequence") + } + + if len(content) == 0 { + memguard.WipeBytes(data) + + return ErrTokenEmpty + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.buffer != nil { + s.buffer.Destroy() + s.buffer = nil + } + + // Allocate secure buffer and copy directly into it + s.buffer = memguard.NewBuffer(len(content)) + copy(s.buffer.Bytes(), content) + + // Wipe the input data + memguard.WipeBytes(data) + + return nil +} + +// TakeFrom transfers the token from src to this SecureToken, destroying any +// existing token. The source token is cleared after transfer. +// This avoids copying the underlying bytes. +func (s *SecureToken) TakeFrom(src *SecureToken) { + if src == nil || s == src { + return + } + + // Extract buffer from source + src.mu.Lock() + buffer := src.buffer + src.buffer = nil + src.mu.Unlock() + + // Install buffer in destination + s.mu.Lock() + if s.buffer != nil { + s.buffer.Destroy() + } + s.buffer = buffer + s.mu.Unlock() +} + +// Equals checks if token matches using constant-time comparison. +// Returns false if the receiver is nil. +func (s *SecureToken) Equals(token string) bool { + if s == nil { + return false + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if s.buffer == nil || !s.buffer.IsAlive() { + return false + } + + return s.buffer.EqualTo([]byte(token)) +} + +// EqualsSecure compares this token with another SecureToken using constant-time comparison. +// Returns false if either receiver or other is nil. +func (s *SecureToken) EqualsSecure(other *SecureToken) bool { + if s == nil || other == nil { + return false + } + + if s == other { + return s.IsSet() + } + + // Get a copy of other's bytes (avoids holding two locks simultaneously) + otherBytes, err := other.Bytes() + if err != nil { + return false + } + defer memguard.WipeBytes(otherBytes) + + s.mu.RLock() + defer s.mu.RUnlock() + + if s.buffer == nil || !s.buffer.IsAlive() { + return false + } + + return s.buffer.EqualTo(otherBytes) +} + +// IsSet returns true if a token is stored. +// Returns false if the receiver is nil. +func (s *SecureToken) IsSet() bool { + if s == nil { + return false + } + + s.mu.RLock() + defer s.mu.RUnlock() + + return s.buffer != nil && s.buffer.IsAlive() +} + +// Bytes returns a copy of the token bytes (for signature generation). +// The caller should zero the returned slice after use. +// Returns ErrTokenNotSet if the receiver is nil. +func (s *SecureToken) Bytes() ([]byte, error) { + if s == nil { + return nil, ErrTokenNotSet + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if s.buffer == nil || !s.buffer.IsAlive() { + return nil, ErrTokenNotSet + } + + // Return a copy (unavoidable for signature generation) + src := s.buffer.Bytes() + result := make([]byte, len(src)) + copy(result, src) + + return result, nil +} + +// Destroy securely wipes the token from memory. +// No-op if the receiver is nil. +func (s *SecureToken) Destroy() { + if s == nil { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.buffer != nil { + s.buffer.Destroy() + s.buffer = nil + } +} diff --git a/envd/internal/api/secure_token_test.go b/envd/internal/api/secure_token_test.go new file mode 100644 index 0000000..3664c6d --- /dev/null +++ b/envd/internal/api/secure_token_test.go @@ -0,0 +1,461 @@ +package api + +import ( + "sync" + "testing" + + "github.com/awnumar/memguard" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecureTokenSetAndEquals(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + + // Initially not set + assert.False(t, st.IsSet(), "token should not be set initially") + assert.False(t, st.Equals("any-token"), "equals should return false when not set") + + // Set token + err := st.Set([]byte("test-token")) + require.NoError(t, err) + assert.True(t, st.IsSet(), "token should be set after Set()") + assert.True(t, st.Equals("test-token"), "equals should return true for correct token") + assert.False(t, st.Equals("wrong-token"), "equals should return false for wrong token") + assert.False(t, st.Equals(""), "equals should return false for empty token") +} + +func TestSecureTokenReplace(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + + // Set initial token + err := st.Set([]byte("first-token")) + require.NoError(t, err) + assert.True(t, st.Equals("first-token")) + + // Replace with new token (old one should be destroyed) + err = st.Set([]byte("second-token")) + require.NoError(t, err) + assert.True(t, st.Equals("second-token"), "should match new token") + assert.False(t, st.Equals("first-token"), "should not match old token") +} + +func TestSecureTokenDestroy(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + + // Set and then destroy + err := st.Set([]byte("test-token")) + require.NoError(t, err) + assert.True(t, st.IsSet()) + + st.Destroy() + assert.False(t, st.IsSet(), "token should not be set after Destroy()") + assert.False(t, st.Equals("test-token"), "equals should return false after Destroy()") + + // Destroy on already destroyed should be safe + st.Destroy() + assert.False(t, st.IsSet()) + + // Nil receiver should be safe + var nilToken *SecureToken + assert.False(t, nilToken.IsSet(), "nil receiver should return false for IsSet()") + assert.False(t, nilToken.Equals("anything"), "nil receiver should return false for Equals()") + assert.False(t, nilToken.EqualsSecure(st), "nil receiver should return false for EqualsSecure()") + nilToken.Destroy() // should not panic + + _, err = nilToken.Bytes() + require.ErrorIs(t, err, ErrTokenNotSet, "nil receiver should return ErrTokenNotSet for Bytes()") +} + +func TestSecureTokenBytes(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + + // Bytes should return error when not set + _, err := st.Bytes() + require.ErrorIs(t, err, ErrTokenNotSet) + + // Set token and get bytes + err = st.Set([]byte("test-token")) + require.NoError(t, err) + + bytes, err := st.Bytes() + require.NoError(t, err) + assert.Equal(t, []byte("test-token"), bytes) + + // Zero out the bytes (as caller should do) + memguard.WipeBytes(bytes) + + // Original should still be intact + assert.True(t, st.Equals("test-token"), "original token should still work after zeroing copy") + + // After destroy, bytes should fail + st.Destroy() + _, err = st.Bytes() + assert.ErrorIs(t, err, ErrTokenNotSet) +} + +func TestSecureTokenConcurrentAccess(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + err := st.Set([]byte("initial-token")) + require.NoError(t, err) + + var wg sync.WaitGroup + const numGoroutines = 100 + + // Concurrent reads + for range numGoroutines { + wg.Go(func() { + st.IsSet() + st.Equals("initial-token") + }) + } + + // Concurrent writes + for i := range 10 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + st.Set([]byte("token-" + string(rune('a'+idx)))) + }(i) + } + + wg.Wait() + + // Should still be in a valid state + assert.True(t, st.IsSet()) +} + +func TestSecureTokenEmptyToken(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + + // Setting empty token should return an error + err := st.Set([]byte{}) + require.ErrorIs(t, err, ErrTokenEmpty) + assert.False(t, st.IsSet(), "token should not be set after empty token error") + + // Setting nil should also return an error + err = st.Set(nil) + require.ErrorIs(t, err, ErrTokenEmpty) + assert.False(t, st.IsSet(), "token should not be set after nil token error") +} + +func TestSecureTokenEmptyTokenDoesNotClearExisting(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + + // Set a valid token first + err := st.Set([]byte("valid-token")) + require.NoError(t, err) + assert.True(t, st.IsSet()) + + // Attempting to set empty token should fail and preserve existing token + err = st.Set([]byte{}) + require.ErrorIs(t, err, ErrTokenEmpty) + assert.True(t, st.IsSet(), "existing token should be preserved after empty token error") + assert.True(t, st.Equals("valid-token"), "existing token value should be unchanged") +} + +func TestSecureTokenUnmarshalJSON(t *testing.T) { + t.Parallel() + + t.Run("unmarshals valid JSON string", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.UnmarshalJSON([]byte(`"my-secret-token"`)) + require.NoError(t, err) + assert.True(t, st.IsSet()) + assert.True(t, st.Equals("my-secret-token")) + }) + + t.Run("returns error for empty string", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.UnmarshalJSON([]byte(`""`)) + require.ErrorIs(t, err, ErrTokenEmpty) + assert.False(t, st.IsSet()) + }) + + t.Run("returns error for invalid JSON", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.UnmarshalJSON([]byte(`not-valid-json`)) + require.Error(t, err) + assert.False(t, st.IsSet()) + }) + + t.Run("replaces existing token", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.Set([]byte("old-token")) + require.NoError(t, err) + + err = st.UnmarshalJSON([]byte(`"new-token"`)) + require.NoError(t, err) + assert.True(t, st.Equals("new-token")) + assert.False(t, st.Equals("old-token")) + }) + + t.Run("wipes input buffer after parsing", func(t *testing.T) { + t.Parallel() + // Create a buffer with a known token + input := []byte(`"secret-token-12345"`) + original := make([]byte, len(input)) + copy(original, input) + + st := &SecureToken{} + err := st.UnmarshalJSON(input) + require.NoError(t, err) + + // Verify the token was stored correctly + assert.True(t, st.Equals("secret-token-12345")) + + // Verify the input buffer was wiped (all zeros) + for i, b := range input { + assert.Equal(t, byte(0), b, "byte at position %d should be zero, got %d", i, b) + } + }) + + t.Run("wipes input buffer on error", func(t *testing.T) { + t.Parallel() + // Create a buffer with an empty token (will error) + input := []byte(`""`) + + st := &SecureToken{} + err := st.UnmarshalJSON(input) + require.Error(t, err) + + // Verify the input buffer was still wiped + for i, b := range input { + assert.Equal(t, byte(0), b, "byte at position %d should be zero, got %d", i, b) + } + }) + + t.Run("rejects escape sequences", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.UnmarshalJSON([]byte(`"token\nwith\nnewlines"`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "escape sequence") + assert.False(t, st.IsSet()) + }) +} + +func TestSecureTokenSetWipesInput(t *testing.T) { + t.Parallel() + + t.Run("wipes input buffer after storing", func(t *testing.T) { + t.Parallel() + // Create a buffer with a known token + input := []byte("my-secret-token") + original := make([]byte, len(input)) + copy(original, input) + + st := &SecureToken{} + err := st.Set(input) + require.NoError(t, err) + + // Verify the token was stored correctly + assert.True(t, st.Equals("my-secret-token")) + + // Verify the input buffer was wiped (all zeros) + for i, b := range input { + assert.Equal(t, byte(0), b, "byte at position %d should be zero, got %d", i, b) + } + }) +} + +func TestSecureTokenTakeFrom(t *testing.T) { + t.Parallel() + + t.Run("transfers token from source to destination", func(t *testing.T) { + t.Parallel() + src := &SecureToken{} + err := src.Set([]byte("source-token")) + require.NoError(t, err) + + dst := &SecureToken{} + dst.TakeFrom(src) + + assert.True(t, dst.IsSet()) + assert.True(t, dst.Equals("source-token")) + assert.False(t, src.IsSet(), "source should be empty after transfer") + }) + + t.Run("replaces existing destination token", func(t *testing.T) { + t.Parallel() + src := &SecureToken{} + err := src.Set([]byte("new-token")) + require.NoError(t, err) + + dst := &SecureToken{} + err = dst.Set([]byte("old-token")) + require.NoError(t, err) + + dst.TakeFrom(src) + + assert.True(t, dst.Equals("new-token")) + assert.False(t, dst.Equals("old-token")) + assert.False(t, src.IsSet()) + }) + + t.Run("handles nil source", func(t *testing.T) { + t.Parallel() + dst := &SecureToken{} + err := dst.Set([]byte("existing-token")) + require.NoError(t, err) + + dst.TakeFrom(nil) + + assert.True(t, dst.IsSet(), "destination should be unchanged with nil source") + assert.True(t, dst.Equals("existing-token")) + }) + + t.Run("handles empty source", func(t *testing.T) { + t.Parallel() + src := &SecureToken{} + dst := &SecureToken{} + err := dst.Set([]byte("existing-token")) + require.NoError(t, err) + + dst.TakeFrom(src) + + assert.False(t, dst.IsSet(), "destination should be cleared when source is empty") + }) + + t.Run("self-transfer is no-op and does not deadlock", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.Set([]byte("token")) + require.NoError(t, err) + + st.TakeFrom(st) + + assert.True(t, st.IsSet(), "token should remain set after self-transfer") + assert.True(t, st.Equals("token"), "token value should be unchanged") + }) +} + +func TestSecureTokenEqualsSecure(t *testing.T) { + t.Parallel() + + t.Run("returns true for matching tokens", func(t *testing.T) { + t.Parallel() + st1 := &SecureToken{} + err := st1.Set([]byte("same-token")) + require.NoError(t, err) + + st2 := &SecureToken{} + err = st2.Set([]byte("same-token")) + require.NoError(t, err) + + assert.True(t, st1.EqualsSecure(st2)) + assert.True(t, st2.EqualsSecure(st1)) + }) + + t.Run("concurrent TakeFrom and EqualsSecure do not deadlock", func(t *testing.T) { + t.Parallel() + // This test verifies the fix for the lock ordering deadlock bug. + + const iterations = 100 + + for range iterations { + a := &SecureToken{} + err := a.Set([]byte("token-a")) + require.NoError(t, err) + + b := &SecureToken{} + err = b.Set([]byte("token-b")) + require.NoError(t, err) + + var wg sync.WaitGroup + wg.Add(2) + + // Goroutine 1: a.TakeFrom(b) + go func() { + defer wg.Done() + a.TakeFrom(b) + }() + + // Goroutine 2: b.EqualsSecure(a) + go func() { + defer wg.Done() + b.EqualsSecure(a) + }() + + wg.Wait() + } + }) + + t.Run("returns false for different tokens", func(t *testing.T) { + t.Parallel() + st1 := &SecureToken{} + err := st1.Set([]byte("token-a")) + require.NoError(t, err) + + st2 := &SecureToken{} + err = st2.Set([]byte("token-b")) + require.NoError(t, err) + + assert.False(t, st1.EqualsSecure(st2)) + }) + + t.Run("returns false when comparing with nil", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.Set([]byte("token")) + require.NoError(t, err) + + assert.False(t, st.EqualsSecure(nil)) + }) + + t.Run("returns false when other is not set", func(t *testing.T) { + t.Parallel() + st1 := &SecureToken{} + err := st1.Set([]byte("token")) + require.NoError(t, err) + + st2 := &SecureToken{} + + assert.False(t, st1.EqualsSecure(st2)) + }) + + t.Run("returns false when self is not set", func(t *testing.T) { + t.Parallel() + st1 := &SecureToken{} + + st2 := &SecureToken{} + err := st2.Set([]byte("token")) + require.NoError(t, err) + + assert.False(t, st1.EqualsSecure(st2)) + }) + + t.Run("self-comparison returns true when set", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.Set([]byte("token")) + require.NoError(t, err) + + assert.True(t, st.EqualsSecure(st), "self-comparison should return true and not deadlock") + }) + + t.Run("self-comparison returns false when not set", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + + assert.False(t, st.EqualsSecure(st), "self-comparison on unset token should return false") + }) +} diff --git a/envd/internal/api/store.go b/envd/internal/api/store.go new file mode 100644 index 0000000..f52f0a0 --- /dev/null +++ b/envd/internal/api/store.go @@ -0,0 +1,93 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "sync" + + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/host" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +// MMDSClient provides access to MMDS metadata. +type MMDSClient interface { + GetAccessTokenHash(ctx context.Context) (string, error) +} + +// DefaultMMDSClient is the production implementation that calls the real MMDS endpoint. +type DefaultMMDSClient struct{} + +func (c *DefaultMMDSClient) GetAccessTokenHash(ctx context.Context) (string, error) { + return host.GetAccessTokenHashFromMMDS(ctx) +} + +type API struct { + isNotFC bool + logger *zerolog.Logger + accessToken *SecureToken + defaults *execcontext.Defaults + + mmdsChan chan *host.MMDSOpts + hyperloopLock sync.Mutex + mmdsClient MMDSClient + + lastSetTime *utils.AtomicMax + initLock sync.Mutex +} + +func New(l *zerolog.Logger, defaults *execcontext.Defaults, mmdsChan chan *host.MMDSOpts, isNotFC bool) *API { + return &API{ + logger: l, + defaults: defaults, + mmdsChan: mmdsChan, + isNotFC: isNotFC, + mmdsClient: &DefaultMMDSClient{}, + lastSetTime: utils.NewAtomicMax(), + accessToken: &SecureToken{}, + } +} + +func (a *API) GetHealth(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + a.logger.Trace().Msg("Health check") + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "") + + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) GetMetrics(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + a.logger.Trace().Msg("Get metrics") + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + + metrics, err := host.GetMetrics() + if err != nil { + a.logger.Error().Err(err).Msg("Failed to get metrics") + w.WriteHeader(http.StatusInternalServerError) + + return + } + + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(metrics); err != nil { + a.logger.Error().Err(err).Msg("Failed to encode metrics") + } +} + +func (a *API) getLogger(err error) *zerolog.Event { + if err != nil { + return a.logger.Error().Err(err) //nolint:zerologlint // this is only prep + } + + return a.logger.Info() //nolint:zerologlint // this is only prep +} diff --git a/envd/internal/api/upload.go b/envd/internal/api/upload.go new file mode 100644 index 0000000..9c7cbeb --- /dev/null +++ b/envd/internal/api/upload.go @@ -0,0 +1,309 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "os/user" + "path/filepath" + "strings" + "syscall" + + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +var ErrNoDiskSpace = fmt.Errorf("not enough disk space available") + +func processFile(r *http.Request, path string, part io.Reader, uid, gid int, logger zerolog.Logger) (int, error) { + logger.Debug(). + Str("path", path). + Msg("File processing") + + err := permissions.EnsureDirs(filepath.Dir(path), uid, gid) + if err != nil { + err := fmt.Errorf("error ensuring directories: %w", err) + + return http.StatusInternalServerError, err + } + + canBePreChowned := false + stat, err := os.Stat(path) + if err != nil && !os.IsNotExist(err) { + errMsg := fmt.Errorf("error getting file info: %w", err) + + return http.StatusInternalServerError, errMsg + } else if err == nil { + if stat.IsDir() { + err := fmt.Errorf("path is a directory: %s", path) + + return http.StatusBadRequest, err + } + canBePreChowned = true + } + + hasBeenChowned := false + if canBePreChowned { + err = os.Chown(path, uid, gid) + if err != nil { + if !os.IsNotExist(err) { + err = fmt.Errorf("error changing file ownership: %w", err) + + return http.StatusInternalServerError, err + } + } else { + hasBeenChowned = true + } + } + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o666) + if err != nil { + if errors.Is(err, syscall.ENOSPC) { + err = fmt.Errorf("not enough inodes available: %w", err) + + return http.StatusInsufficientStorage, err + } + + err := fmt.Errorf("error opening file: %w", err) + + return http.StatusInternalServerError, err + } + + defer file.Close() + + if !hasBeenChowned { + err = os.Chown(path, uid, gid) + if err != nil { + err := fmt.Errorf("error changing file ownership: %w", err) + + return http.StatusInternalServerError, err + } + } + + _, err = file.ReadFrom(part) + if err != nil { + if errors.Is(err, syscall.ENOSPC) { + err = ErrNoDiskSpace + if r.ContentLength > 0 { + err = fmt.Errorf("attempted to write %d bytes: %w", r.ContentLength, err) + } + + return http.StatusInsufficientStorage, err + } + + err = fmt.Errorf("error writing file: %w", err) + + return http.StatusInternalServerError, err + } + + return http.StatusNoContent, nil +} + +func resolvePath(part *multipart.Part, paths *UploadSuccess, u *user.User, defaultPath *string, params PostFilesParams) (string, error) { + var pathToResolve string + + if params.Path != nil { + pathToResolve = *params.Path + } else { + var err error + customPart := utils.NewCustomPart(part) + pathToResolve, err = customPart.FileNameWithPath() + if err != nil { + return "", fmt.Errorf("error getting multipart custom part file name: %w", err) + } + } + + filePath, err := permissions.ExpandAndResolve(pathToResolve, u, defaultPath) + if err != nil { + return "", fmt.Errorf("error resolving path: %w", err) + } + + for _, entry := range *paths { + if entry.Path == filePath { + var alreadyUploaded []string + for _, uploadedFile := range *paths { + if uploadedFile.Path != filePath { + alreadyUploaded = append(alreadyUploaded, uploadedFile.Path) + } + } + + errMsg := fmt.Errorf("you cannot upload multiple files to the same path '%s' in one upload request, only the first specified file was uploaded", filePath) + + if len(alreadyUploaded) > 1 { + errMsg = fmt.Errorf("%w, also the following files were uploaded: %v", errMsg, strings.Join(alreadyUploaded, ", ")) + } + + return "", errMsg + } + } + + return filePath, nil +} + +func (a *API) handlePart(r *http.Request, part *multipart.Part, paths UploadSuccess, u *user.User, uid, gid int, operationID string, params PostFilesParams) (*EntryInfo, int, error) { + defer part.Close() + + if part.FormName() != "file" { + return nil, http.StatusOK, nil + } + + filePath, err := resolvePath(part, &paths, u, a.defaults.Workdir, params) + if err != nil { + return nil, http.StatusBadRequest, err + } + + logger := a.logger. + With(). + Str(string(logs.OperationIDKey), operationID). + Str("event_type", "file_processing"). + Logger() + + status, err := processFile(r, filePath, part, uid, gid, logger) + if err != nil { + return nil, status, err + } + + return &EntryInfo{ + Path: filePath, + Name: filepath.Base(filePath), + Type: File, + }, http.StatusOK, nil +} + +func (a *API) PostFiles(w http.ResponseWriter, r *http.Request, params PostFilesParams) { + // Capture original body to ensure it's always closed + originalBody := r.Body + defer originalBody.Close() + + var errorCode int + var errMsg error + + var path string + if params.Path != nil { + path = *params.Path + } + + operationID := logs.AssignOperationID() + + // signing authorization if needed + err := a.validateSigning(r, params.Signature, params.SignatureExpiration, params.Username, path, SigningWriteOperation) + if err != nil { + a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("error during auth validation") + jsonError(w, http.StatusUnauthorized, err) + + return + } + + username, err := execcontext.ResolveDefaultUsername(params.Username, a.defaults.User) + if err != nil { + a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("no user specified") + jsonError(w, http.StatusBadRequest, err) + + return + } + + defer func() { + l := a.logger. + Err(errMsg). + Str("method", r.Method+" "+r.URL.Path). + Str(string(logs.OperationIDKey), operationID). + Str("path", path). + Str("username", username) + + if errMsg != nil { + l = l.Int("error_code", errorCode) + } + + l.Msg("File write") + }() + + // Handle gzip-encoded request body + body, err := getDecompressedBody(r) + if err != nil { + errMsg = fmt.Errorf("error decompressing request body: %w", err) + errorCode = http.StatusBadRequest + jsonError(w, errorCode, errMsg) + + return + } + defer body.Close() + r.Body = body + + f, err := r.MultipartReader() + if err != nil { + errMsg = fmt.Errorf("error parsing multipart form: %w", err) + errorCode = http.StatusInternalServerError + jsonError(w, errorCode, errMsg) + + return + } + + u, err := user.Lookup(username) + if err != nil { + errMsg = fmt.Errorf("error looking up user '%s': %w", username, err) + errorCode = http.StatusUnauthorized + + jsonError(w, errorCode, errMsg) + + return + } + + uid, gid, err := permissions.GetUserIdInts(u) + if err != nil { + errMsg = fmt.Errorf("error getting user ids: %w", err) + + jsonError(w, http.StatusInternalServerError, errMsg) + + return + } + + paths := UploadSuccess{} + + for { + part, partErr := f.NextPart() + + if partErr == io.EOF { + // We're done reading the parts. + break + } else if partErr != nil { + errMsg = fmt.Errorf("error reading form: %w", partErr) + errorCode = http.StatusInternalServerError + jsonError(w, errorCode, errMsg) + + break + } + + entry, status, err := a.handlePart(r, part, paths, u, uid, gid, operationID, params) + if err != nil { + errorCode = status + errMsg = err + jsonError(w, errorCode, errMsg) + + return + } + + if entry != nil { + paths = append(paths, *entry) + } + } + + data, err := json.Marshal(paths) + if err != nil { + errMsg = fmt.Errorf("error marshaling response: %w", err) + errorCode = http.StatusInternalServerError + jsonError(w, errorCode, errMsg) + + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} diff --git a/envd/internal/api/upload_test.go b/envd/internal/api/upload_test.go new file mode 100644 index 0000000..0d66b0b --- /dev/null +++ b/envd/internal/api/upload_test.go @@ -0,0 +1,249 @@ +package api + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProcessFile(t *testing.T) { + t.Parallel() + uid := os.Getuid() + gid := os.Getgid() + + newRequest := func(content []byte) (*http.Request, io.Reader) { + request := &http.Request{ + ContentLength: int64(len(content)), + } + buffer := bytes.NewBuffer(content) + + return request, buffer + } + + var emptyReq http.Request + var emptyPart *bytes.Buffer + var emptyLogger zerolog.Logger + + t.Run("failed to ensure directories", func(t *testing.T) { + t.Parallel() + httpStatus, err := processFile(&emptyReq, "/proc/invalid/not-real", emptyPart, uid, gid, emptyLogger) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httpStatus) + assert.ErrorContains(t, err, "error ensuring directories: ") + }) + + t.Run("attempt to replace directory with a file", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + + httpStatus, err := processFile(&emptyReq, tempDir, emptyPart, uid, gid, emptyLogger) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httpStatus, err.Error()) + assert.ErrorContains(t, err, "path is a directory: ") + }) + + t.Run("fail to create file", func(t *testing.T) { + t.Parallel() + httpStatus, err := processFile(&emptyReq, "/proc/invalid-filename", emptyPart, uid, gid, emptyLogger) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httpStatus) + assert.ErrorContains(t, err, "error opening file: ") + }) + + t.Run("out of disk space", func(t *testing.T) { + t.Parallel() + // make a tiny tmpfs mount + mountSize := 1024 + tempDir := createTmpfsMount(t, mountSize) + + // create test file + firstFileSize := mountSize / 2 + tempFile1 := filepath.Join(tempDir, "test-file-1") + + // fill it up + cmd := exec.CommandContext(t.Context(), + "dd", "if=/dev/zero", "of="+tempFile1, fmt.Sprintf("bs=%d", firstFileSize), "count=1") + err := cmd.Run() + require.NoError(t, err) + + // create a new file that would fill up the + secondFileContents := make([]byte, mountSize*2) + for index := range secondFileContents { + secondFileContents[index] = 'a' + } + + // try to replace it + request, buffer := newRequest(secondFileContents) + tempFile2 := filepath.Join(tempDir, "test-file-2") + httpStatus, err := processFile(request, tempFile2, buffer, uid, gid, emptyLogger) + require.Error(t, err) + assert.Equal(t, http.StatusInsufficientStorage, httpStatus) + assert.ErrorContains(t, err, "attempted to write 2048 bytes: not enough disk space") + }) + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "test-file") + + content := []byte("test-file-contents") + request, buffer := newRequest(content) + + httpStatus, err := processFile(request, tempFile, buffer, uid, gid, emptyLogger) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, httpStatus) + + data, err := os.ReadFile(tempFile) + require.NoError(t, err) + assert.Equal(t, content, data) + }) + + t.Run("overwrite file on full disk", func(t *testing.T) { + t.Parallel() + // make a tiny tmpfs mount + sizeInBytes := 1024 + tempDir := createTmpfsMount(t, 1024) + + // create test file + tempFile := filepath.Join(tempDir, "test-file") + + // fill it up + cmd := exec.CommandContext(t.Context(), "dd", "if=/dev/zero", "of="+tempFile, fmt.Sprintf("bs=%d", sizeInBytes), "count=1") + err := cmd.Run() + require.NoError(t, err) + + // try to replace it + content := []byte("test-file-contents") + request, buffer := newRequest(content) + httpStatus, err := processFile(request, tempFile, buffer, uid, gid, emptyLogger) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, httpStatus) + }) + + t.Run("write new file on full disk", func(t *testing.T) { + t.Parallel() + // make a tiny tmpfs mount + sizeInBytes := 1024 + tempDir := createTmpfsMount(t, 1024) + + // create test file + tempFile1 := filepath.Join(tempDir, "test-file") + + // fill it up + cmd := exec.CommandContext(t.Context(), "dd", "if=/dev/zero", "of="+tempFile1, fmt.Sprintf("bs=%d", sizeInBytes), "count=1") + err := cmd.Run() + require.NoError(t, err) + + // try to write a new file + tempFile2 := filepath.Join(tempDir, "test-file-2") + content := []byte("test-file-contents") + request, buffer := newRequest(content) + httpStatus, err := processFile(request, tempFile2, buffer, uid, gid, emptyLogger) + require.ErrorContains(t, err, "not enough disk space available") + assert.Equal(t, http.StatusInsufficientStorage, httpStatus) + }) + + t.Run("write new file with no inodes available", func(t *testing.T) { + t.Parallel() + // make a tiny tmpfs mount + tempDir := createTmpfsMountWithInodes(t, 1024, 2) + + // create test file + tempFile1 := filepath.Join(tempDir, "test-file") + + // fill it up + cmd := exec.CommandContext(t.Context(), "dd", "if=/dev/zero", "of="+tempFile1, fmt.Sprintf("bs=%d", 100), "count=1") + err := cmd.Run() + require.NoError(t, err) + + // try to write a new file + tempFile2 := filepath.Join(tempDir, "test-file-2") + content := []byte("test-file-contents") + request, buffer := newRequest(content) + httpStatus, err := processFile(request, tempFile2, buffer, uid, gid, emptyLogger) + require.ErrorContains(t, err, "not enough inodes available") + assert.Equal(t, http.StatusInsufficientStorage, httpStatus) + }) + + t.Run("update sysfs or other virtual fs", func(t *testing.T) { + t.Parallel() + if os.Geteuid() != 0 { + t.Skip("skipping sysfs updates: Operation not permitted with non-root user") + } + + filePath := "/sys/fs/cgroup/user.slice/cpu.weight" + newContent := []byte("102\n") + request, buffer := newRequest(newContent) + + httpStatus, err := processFile(request, filePath, buffer, uid, gid, emptyLogger) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, httpStatus) + + data, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, newContent, data) + }) + + t.Run("replace file", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "test-file") + + err := os.WriteFile(tempFile, []byte("old-contents"), 0o644) + require.NoError(t, err) + + newContent := []byte("new-file-contents") + request, buffer := newRequest(newContent) + + httpStatus, err := processFile(request, tempFile, buffer, uid, gid, emptyLogger) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, httpStatus) + + data, err := os.ReadFile(tempFile) + require.NoError(t, err) + assert.Equal(t, newContent, data) + }) +} + +func createTmpfsMount(t *testing.T, sizeInBytes int) string { + t.Helper() + + return createTmpfsMountWithInodes(t, sizeInBytes, 5) +} + +func createTmpfsMountWithInodes(t *testing.T, sizeInBytes, inodesCount int) string { + t.Helper() + + if os.Geteuid() != 0 { + t.Skip("skipping sysfs updates: Operation not permitted with non-root user") + } + + tempDir := t.TempDir() + + cmd := exec.CommandContext(t.Context(), + "mount", + "tmpfs", + tempDir, + "-t", "tmpfs", + "-o", fmt.Sprintf("size=%d,nr_inodes=%d", sizeInBytes, inodesCount)) + err := cmd.Run() + require.NoError(t, err) + t.Cleanup(func() { + ctx := context.WithoutCancel(t.Context()) + cmd := exec.CommandContext(ctx, "umount", tempDir) + err := cmd.Run() + require.NoError(t, err) + }) + + return tempDir +} diff --git a/envd/internal/execcontext/context.go b/envd/internal/execcontext/context.go new file mode 100644 index 0000000..03bac33 --- /dev/null +++ b/envd/internal/execcontext/context.go @@ -0,0 +1,37 @@ +package execcontext + +import ( + "errors" + + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +type Defaults struct { + EnvVars *utils.Map[string, string] + User string + Workdir *string +} + +func ResolveDefaultWorkdir(workdir string, defaultWorkdir *string) string { + if workdir != "" { + return workdir + } + + if defaultWorkdir != nil { + return *defaultWorkdir + } + + return "" +} + +func ResolveDefaultUsername(username *string, defaultUsername string) (string, error) { + if username != nil { + return *username, nil + } + + if defaultUsername != "" { + return defaultUsername, nil + } + + return "", errors.New("username not provided") +} diff --git a/envd/internal/filesystem/.gitkeep b/envd/internal/filesystem/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/envd/internal/host/metrics.go b/envd/internal/host/metrics.go new file mode 100644 index 0000000..1f09837 --- /dev/null +++ b/envd/internal/host/metrics.go @@ -0,0 +1,93 @@ +package host + +import ( + "math" + "time" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/mem" + "golang.org/x/sys/unix" +) + +type Metrics struct { + Timestamp int64 `json:"ts"` // Unix Timestamp in UTC + + CPUCount uint32 `json:"cpu_count"` // Total CPU cores + CPUUsedPercent float32 `json:"cpu_used_pct"` // Percent rounded to 2 decimal places + + // Deprecated: kept for backwards compatibility with older orchestrators. + MemTotalMiB uint64 `json:"mem_total_mib"` // Total virtual memory in MiB + + // Deprecated: kept for backwards compatibility with older orchestrators. + MemUsedMiB uint64 `json:"mem_used_mib"` // Used virtual memory in MiB + + MemTotal uint64 `json:"mem_total"` // Total virtual memory in bytes + MemUsed uint64 `json:"mem_used"` // Used virtual memory in bytes + + DiskUsed uint64 `json:"disk_used"` // Used disk space in bytes + DiskTotal uint64 `json:"disk_total"` // Total disk space in bytes +} + +func GetMetrics() (*Metrics, error) { + v, err := mem.VirtualMemory() + if err != nil { + return nil, err + } + + memUsedMiB := v.Used / 1024 / 1024 + memTotalMiB := v.Total / 1024 / 1024 + + cpuTotal, err := cpu.Counts(true) + if err != nil { + return nil, err + } + + cpuUsedPcts, err := cpu.Percent(0, false) + if err != nil { + return nil, err + } + + cpuUsedPct := cpuUsedPcts[0] + cpuUsedPctRounded := float32(cpuUsedPct) + if cpuUsedPct > 0 { + cpuUsedPctRounded = float32(math.Round(cpuUsedPct*100) / 100) + } + + diskMetrics, err := diskStats("/") + if err != nil { + return nil, err + } + + return &Metrics{ + Timestamp: time.Now().UTC().Unix(), + CPUCount: uint32(cpuTotal), + CPUUsedPercent: cpuUsedPctRounded, + MemUsedMiB: memUsedMiB, + MemTotalMiB: memTotalMiB, + MemTotal: v.Total, + MemUsed: v.Used, + DiskUsed: diskMetrics.Total - diskMetrics.Available, + DiskTotal: diskMetrics.Total, + }, nil +} + +type diskSpace struct { + Total uint64 + Available uint64 +} + +func diskStats(path string) (diskSpace, error) { + var st unix.Statfs_t + if err := unix.Statfs(path, &st); err != nil { + return diskSpace{}, err + } + + block := uint64(st.Bsize) + + // all data blocks + total := st.Blocks * block + // blocks available + available := st.Bavail * block + + return diskSpace{Total: total, Available: available}, nil +} diff --git a/envd/internal/host/mmds.go b/envd/internal/host/mmds.go new file mode 100644 index 0000000..7d2976c --- /dev/null +++ b/envd/internal/host/mmds.go @@ -0,0 +1,182 @@ +package host + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +const ( + WrennRunDir = "/run/wrenn" // store sandbox metadata files here + + mmdsDefaultAddress = "169.254.169.254" + mmdsTokenExpiration = 60 * time.Second + + mmdsAccessTokenRequestClientTimeout = 10 * time.Second +) + +var mmdsAccessTokenClient = &http.Client{ + Timeout: mmdsAccessTokenRequestClientTimeout, + Transport: &http.Transport{ + DisableKeepAlives: true, + }, +} + +type MMDSOpts struct { + SandboxID string `json:"instanceID"` + TemplateID string `json:"envID"` + LogsCollectorAddress string `json:"address"` + AccessTokenHash string `json:"accessTokenHash"` +} + +func (opts *MMDSOpts) Update(sandboxID, templateID, collectorAddress string) { + opts.SandboxID = sandboxID + opts.TemplateID = templateID + opts.LogsCollectorAddress = collectorAddress +} + +func (opts *MMDSOpts) AddOptsToJSON(jsonLogs []byte) ([]byte, error) { + parsed := make(map[string]any) + + err := json.Unmarshal(jsonLogs, &parsed) + if err != nil { + return nil, err + } + + parsed["instanceID"] = opts.SandboxID + parsed["envID"] = opts.TemplateID + + data, err := json.Marshal(parsed) + + return data, err +} + +func getMMDSToken(ctx context.Context, client *http.Client) (string, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://"+mmdsDefaultAddress+"/latest/api/token", &bytes.Buffer{}) + if err != nil { + return "", err + } + + request.Header["X-metadata-token-ttl-seconds"] = []string{fmt.Sprint(mmdsTokenExpiration.Seconds())} + + response, err := client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + token := string(body) + + if len(token) == 0 { + return "", fmt.Errorf("mmds token is an empty string") + } + + return token, nil +} + +func getMMDSOpts(ctx context.Context, client *http.Client, token string) (*MMDSOpts, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+mmdsDefaultAddress, &bytes.Buffer{}) + if err != nil { + return nil, err + } + + request.Header["X-metadata-token"] = []string{token} + request.Header["Accept"] = []string{"application/json"} + + response, err := client.Do(request) + if err != nil { + return nil, err + } + + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + var opts MMDSOpts + + err = json.Unmarshal(body, &opts) + if err != nil { + return nil, err + } + + return &opts, nil +} + +// GetAccessTokenHashFromMMDS reads the access token hash from MMDS. +// This is used to validate that /init requests come from the orchestrator. +func GetAccessTokenHashFromMMDS(ctx context.Context) (string, error) { + token, err := getMMDSToken(ctx, mmdsAccessTokenClient) + if err != nil { + return "", fmt.Errorf("failed to get MMDS token: %w", err) + } + + opts, err := getMMDSOpts(ctx, mmdsAccessTokenClient, token) + if err != nil { + return "", fmt.Errorf("failed to get MMDS opts: %w", err) + } + + return opts.AccessTokenHash, nil +} + +func PollForMMDSOpts(ctx context.Context, mmdsChan chan<- *MMDSOpts, envVars *utils.Map[string, string]) { + httpClient := &http.Client{} + defer httpClient.CloseIdleConnections() + + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + fmt.Fprintf(os.Stderr, "context cancelled while waiting for mmds opts") + + return + case <-ticker.C: + token, err := getMMDSToken(ctx, httpClient) + if err != nil { + fmt.Fprintf(os.Stderr, "error getting mmds token: %v\n", err) + + continue + } + + mmdsOpts, err := getMMDSOpts(ctx, httpClient, token) + if err != nil { + fmt.Fprintf(os.Stderr, "error getting mmds opts: %v\n", err) + + continue + } + + envVars.Store("WRENN_SANDBOX_ID", mmdsOpts.SandboxID) + envVars.Store("WRENN_TEMPLATE_ID", mmdsOpts.TemplateID) + + if err := os.WriteFile(filepath.Join(WrennRunDir, ".WRENN_SANDBOX_ID"), []byte(mmdsOpts.SandboxID), 0o666); err != nil { + fmt.Fprintf(os.Stderr, "error writing sandbox ID file: %v\n", err) + } + if err := os.WriteFile(filepath.Join(WrennRunDir, ".WRENN_TEMPLATE_ID"), []byte(mmdsOpts.TemplateID), 0o666); err != nil { + fmt.Fprintf(os.Stderr, "error writing template ID file: %v\n", err) + } + + if mmdsOpts.LogsCollectorAddress != "" { + mmdsChan <- mmdsOpts + } + + return + } + } +} diff --git a/envd/internal/logs/bufferedEvents.go b/envd/internal/logs/bufferedEvents.go new file mode 100644 index 0000000..7e7da48 --- /dev/null +++ b/envd/internal/logs/bufferedEvents.go @@ -0,0 +1,47 @@ +package logs + +import ( + "time" + + "github.com/rs/zerolog" +) + +const ( + defaultMaxBufferSize = 2 << 15 + defaultTimeout = 2 * time.Second +) + +func LogBufferedDataEvents(dataCh <-chan []byte, logger *zerolog.Logger, eventType string) { + timer := time.NewTicker(defaultTimeout) + defer timer.Stop() + + var buffer []byte + defer func() { + if len(buffer) > 0 { + logger.Info().Str(eventType, string(buffer)).Msg("Streaming process event (flush)") + } + }() + + for { + select { + case <-timer.C: + if len(buffer) > 0 { + logger.Info().Str(eventType, string(buffer)).Msg("Streaming process event") + buffer = nil + } + case data, ok := <-dataCh: + if !ok { + return + } + + buffer = append(buffer, data...) + + if len(buffer) >= defaultMaxBufferSize { + logger.Info().Str(eventType, string(buffer)).Msg("Streaming process event") + buffer = nil + + continue + } + } + } +} diff --git a/envd/internal/logs/exporter/exporter.go b/envd/internal/logs/exporter/exporter.go new file mode 100644 index 0000000..f07d872 --- /dev/null +++ b/envd/internal/logs/exporter/exporter.go @@ -0,0 +1,172 @@ +package exporter + +import ( + "bytes" + "context" + "fmt" + "log" + "net/http" + "os" + "sync" + "time" + + "git.omukk.dev/wrenn/sandbox/envd/internal/host" +) + +const ExporterTimeout = 10 * time.Second + +type HTTPExporter struct { + client http.Client + logs [][]byte + isNotFC bool + mmdsOpts *host.MMDSOpts + + // Concurrency coordination + triggers chan struct{} + logLock sync.RWMutex + mmdsLock sync.RWMutex + startOnce sync.Once +} + +func NewHTTPLogsExporter(ctx context.Context, isNotFC bool, mmdsChan <-chan *host.MMDSOpts) *HTTPExporter { + exporter := &HTTPExporter{ + client: http.Client{ + Timeout: ExporterTimeout, + }, + triggers: make(chan struct{}, 1), + isNotFC: isNotFC, + startOnce: sync.Once{}, + mmdsOpts: &host.MMDSOpts{ + SandboxID: "unknown", + TemplateID: "unknown", + LogsCollectorAddress: "", + }, + } + + go exporter.listenForMMDSOptsAndStart(ctx, mmdsChan) + + return exporter +} + +func (w *HTTPExporter) sendInstanceLogs(ctx context.Context, logs []byte, address string) error { + if address == "" { + return nil + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, address, bytes.NewBuffer(logs)) + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := w.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + return nil +} + +func printLog(logs []byte) { + fmt.Fprintf(os.Stdout, "%v", string(logs)) +} + +func (w *HTTPExporter) listenForMMDSOptsAndStart(ctx context.Context, mmdsChan <-chan *host.MMDSOpts) { + for { + select { + case <-ctx.Done(): + return + case mmdsOpts, ok := <-mmdsChan: + if !ok { + return + } + + w.mmdsLock.Lock() + w.mmdsOpts.Update(mmdsOpts.SandboxID, mmdsOpts.TemplateID, mmdsOpts.LogsCollectorAddress) + w.mmdsLock.Unlock() + + w.startOnce.Do(func() { + go w.start(ctx) + }) + } + } +} + +func (w *HTTPExporter) start(ctx context.Context) { + for range w.triggers { + logs := w.getAllLogs() + + if len(logs) == 0 { + continue + } + + if w.isNotFC { + for _, log := range logs { + fmt.Fprintf(os.Stdout, "%v", string(log)) + } + + continue + } + + for _, logLine := range logs { + w.mmdsLock.RLock() + logLineWithOpts, err := w.mmdsOpts.AddOptsToJSON(logLine) + w.mmdsLock.RUnlock() + if err != nil { + log.Printf("error adding instance logging options (%+v) to JSON (%+v) with logs : %v\n", w.mmdsOpts, logLine, err) + + printLog(logLine) + + continue + } + + err = w.sendInstanceLogs(ctx, logLineWithOpts, w.mmdsOpts.LogsCollectorAddress) + if err != nil { + log.Printf("error sending instance logs: %+v", err) + + printLog(logLine) + + continue + } + } + } +} + +func (w *HTTPExporter) resumeProcessing() { + select { + case w.triggers <- struct{}{}: + default: + // Exporter processing already triggered + // This is expected behavior if the exporter is already processing logs + } +} + +func (w *HTTPExporter) Write(logs []byte) (int, error) { + logsCopy := make([]byte, len(logs)) + copy(logsCopy, logs) + + go w.addLogs(logsCopy) + + return len(logs), nil +} + +func (w *HTTPExporter) getAllLogs() [][]byte { + w.logLock.Lock() + defer w.logLock.Unlock() + + logs := w.logs + w.logs = nil + + return logs +} + +func (w *HTTPExporter) addLogs(logs []byte) { + w.logLock.Lock() + defer w.logLock.Unlock() + + w.logs = append(w.logs, logs) + + w.resumeProcessing() +} diff --git a/envd/internal/logs/interceptor.go b/envd/internal/logs/interceptor.go new file mode 100644 index 0000000..92b99c0 --- /dev/null +++ b/envd/internal/logs/interceptor.go @@ -0,0 +1,172 @@ +package logs + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync/atomic" + + "connectrpc.com/connect" + "github.com/rs/zerolog" +) + +type OperationID string + +const ( + OperationIDKey OperationID = "operation_id" + DefaultHTTPMethod string = "POST" +) + +var operationID = atomic.Int32{} + +func AssignOperationID() string { + id := operationID.Add(1) + + return strconv.Itoa(int(id)) +} + +func AddRequestIDToContext(ctx context.Context) context.Context { + return context.WithValue(ctx, OperationIDKey, AssignOperationID()) +} + +func formatMethod(method string) string { + parts := strings.Split(method, ".") + if len(parts) < 2 { + return method + } + + split := strings.Split(parts[1], "/") + if len(split) < 2 { + return method + } + + servicePart := split[0] + servicePart = strings.ToUpper(servicePart[:1]) + servicePart[1:] + + methodPart := split[1] + methodPart = strings.ToLower(methodPart[:1]) + methodPart[1:] + + return fmt.Sprintf("%s %s", servicePart, methodPart) +} + +func NewUnaryLogInterceptor(logger *zerolog.Logger) connect.UnaryInterceptorFunc { + interceptor := func(next connect.UnaryFunc) connect.UnaryFunc { + return connect.UnaryFunc(func( + ctx context.Context, + req connect.AnyRequest, + ) (connect.AnyResponse, error) { + ctx = AddRequestIDToContext(ctx) + + res, err := next(ctx, req) + + l := logger. + Err(err). + Str("method", DefaultHTTPMethod+" "+req.Spec().Procedure). + Str(string(OperationIDKey), ctx.Value(OperationIDKey).(string)) + + if err != nil { + l = l.Int("error_code", int(connect.CodeOf(err))) + } + + if req != nil { + l = l.Interface("request", req.Any()) + } + + if res != nil && err == nil { + l = l.Interface("response", res.Any()) + } + + if res == nil && err == nil { + l = l.Interface("response", nil) + } + + l.Msg(formatMethod(req.Spec().Procedure)) + + return res, err + }) + } + + return connect.UnaryInterceptorFunc(interceptor) +} + +func LogServerStreamWithoutEvents[T any, R any]( + ctx context.Context, + logger *zerolog.Logger, + req *connect.Request[R], + stream *connect.ServerStream[T], + handler func(ctx context.Context, req *connect.Request[R], stream *connect.ServerStream[T]) error, +) error { + ctx = AddRequestIDToContext(ctx) + + l := logger.Debug(). + Str("method", DefaultHTTPMethod+" "+req.Spec().Procedure). + Str(string(OperationIDKey), ctx.Value(OperationIDKey).(string)) + + if req != nil { + l = l.Interface("request", req.Any()) + } + + l.Msg(fmt.Sprintf("%s (server stream start)", formatMethod(req.Spec().Procedure))) + + err := handler(ctx, req, stream) + + logEvent := getErrDebugLogEvent(logger, err). + Str("method", DefaultHTTPMethod+" "+req.Spec().Procedure). + Str(string(OperationIDKey), ctx.Value(OperationIDKey).(string)) + + if err != nil { + logEvent = logEvent.Int("error_code", int(connect.CodeOf(err))) + } else { + logEvent = logEvent.Interface("response", nil) + } + + logEvent.Msg(fmt.Sprintf("%s (server stream end)", formatMethod(req.Spec().Procedure))) + + return err +} + +func LogClientStreamWithoutEvents[T any, R any]( + ctx context.Context, + logger *zerolog.Logger, + stream *connect.ClientStream[T], + handler func(ctx context.Context, stream *connect.ClientStream[T]) (*connect.Response[R], error), +) (*connect.Response[R], error) { + ctx = AddRequestIDToContext(ctx) + + logger.Debug(). + Str("method", DefaultHTTPMethod+" "+stream.Spec().Procedure). + Str(string(OperationIDKey), ctx.Value(OperationIDKey).(string)). + Msg(fmt.Sprintf("%s (client stream start)", formatMethod(stream.Spec().Procedure))) + + res, err := handler(ctx, stream) + + logEvent := getErrDebugLogEvent(logger, err). + Str("method", DefaultHTTPMethod+" "+stream.Spec().Procedure). + Str(string(OperationIDKey), ctx.Value(OperationIDKey).(string)) + + if err != nil { + logEvent = logEvent.Int("error_code", int(connect.CodeOf(err))) + } + + if res != nil && err == nil { + logEvent = logEvent.Interface("response", res.Any()) + } + + if res == nil && err == nil { + logEvent = logEvent.Interface("response", nil) + } + + logEvent.Msg(fmt.Sprintf("%s (client stream end)", formatMethod(stream.Spec().Procedure))) + + return res, err +} + +// Return logger with error level if err is not nil, otherwise return logger with debug level +func getErrDebugLogEvent(logger *zerolog.Logger, err error) *zerolog.Event { + if err != nil { + return logger.Error().Err(err) //nolint:zerologlint // this builds an event, it is not expected to return it + } + + return logger.Debug() //nolint:zerologlint // this builds an event, it is not expected to return it +} diff --git a/envd/internal/logs/logger.go b/envd/internal/logs/logger.go new file mode 100644 index 0000000..0aef809 --- /dev/null +++ b/envd/internal/logs/logger.go @@ -0,0 +1,35 @@ +package logs + +import ( + "context" + "io" + "os" + "time" + + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/host" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs/exporter" +) + +func NewLogger(ctx context.Context, isNotFC bool, mmdsChan <-chan *host.MMDSOpts) *zerolog.Logger { + zerolog.TimestampFieldName = "timestamp" + zerolog.TimeFieldFormat = time.RFC3339Nano + + exporters := []io.Writer{} + + if isNotFC { + exporters = append(exporters, os.Stdout) + } else { + exporters = append(exporters, exporter.NewHTTPLogsExporter(ctx, isNotFC, mmdsChan), os.Stdout) + } + + l := zerolog. + New(io.MultiWriter(exporters...)). + With(). + Timestamp(). + Logger(). + Level(zerolog.DebugLevel) + + return &l +} diff --git a/envd/internal/network/.gitkeep b/envd/internal/network/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/envd/internal/permissions/authenticate.go b/envd/internal/permissions/authenticate.go new file mode 100644 index 0000000..8b525dc --- /dev/null +++ b/envd/internal/permissions/authenticate.go @@ -0,0 +1,47 @@ +package permissions + +import ( + "context" + "fmt" + "os/user" + + "connectrpc.com/authn" + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" +) + +func AuthenticateUsername(_ context.Context, req authn.Request) (any, error) { + username, _, ok := req.BasicAuth() + if !ok { + // When no username is provided, ignore the authentication method (not all endpoints require it) + // Missing user is then handled in the GetAuthUser function + return nil, nil + } + + u, err := GetUser(username) + if err != nil { + return nil, authn.Errorf("invalid username: '%s'", username) + } + + return u, nil +} + +func GetAuthUser(ctx context.Context, defaultUser string) (*user.User, error) { + u, ok := authn.GetInfo(ctx).(*user.User) + if !ok { + username, err := execcontext.ResolveDefaultUsername(nil, defaultUser) + if err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("no user specified")) + } + + u, err := GetUser(username) + if err != nil { + return nil, authn.Errorf("invalid default user: '%s'", username) + } + + return u, nil + } + + return u, nil +} diff --git a/envd/internal/permissions/keepalive.go b/envd/internal/permissions/keepalive.go new file mode 100644 index 0000000..3d7a046 --- /dev/null +++ b/envd/internal/permissions/keepalive.go @@ -0,0 +1,29 @@ +package permissions + +import ( + "strconv" + "time" + + "connectrpc.com/connect" +) + +const defaultKeepAliveInterval = 90 * time.Second + +func GetKeepAliveTicker[T any](req *connect.Request[T]) (*time.Ticker, func()) { + keepAliveIntervalHeader := req.Header().Get("Keepalive-Ping-Interval") + + var interval time.Duration + + keepAliveIntervalInt, err := strconv.Atoi(keepAliveIntervalHeader) + if err != nil { + interval = defaultKeepAliveInterval + } else { + interval = time.Duration(keepAliveIntervalInt) * time.Second + } + + ticker := time.NewTicker(interval) + + return ticker, func() { + ticker.Reset(interval) + } +} diff --git a/envd/internal/permissions/path.go b/envd/internal/permissions/path.go new file mode 100644 index 0000000..8ac278b --- /dev/null +++ b/envd/internal/permissions/path.go @@ -0,0 +1,96 @@ +package permissions + +import ( + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "slices" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" +) + +func expand(path, homedir string) (string, error) { + if len(path) == 0 { + return path, nil + } + + if path[0] != '~' { + return path, nil + } + + if len(path) > 1 && path[1] != '/' && path[1] != '\\' { + return "", errors.New("cannot expand user-specific home dir") + } + + return filepath.Join(homedir, path[1:]), nil +} + +func ExpandAndResolve(path string, user *user.User, defaultPath *string) (string, error) { + path = execcontext.ResolveDefaultWorkdir(path, defaultPath) + + path, err := expand(path, user.HomeDir) + if err != nil { + return "", fmt.Errorf("failed to expand path '%s' for user '%s': %w", path, user.Username, err) + } + + if filepath.IsAbs(path) { + return path, nil + } + + // The filepath.Abs can correctly resolve paths like /home/user/../file + path = filepath.Join(user.HomeDir, path) + + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to resolve path '%s' for user '%s' with home dir '%s': %w", path, user.Username, user.HomeDir, err) + } + + return abs, nil +} + +func getSubpaths(path string) (subpaths []string) { + for { + subpaths = append(subpaths, path) + + path = filepath.Dir(path) + if path == "/" { + break + } + } + + slices.Reverse(subpaths) + + return subpaths +} + +func EnsureDirs(path string, uid, gid int) error { + subpaths := getSubpaths(path) + for _, subpath := range subpaths { + info, err := os.Stat(subpath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to stat directory: %w", err) + } + + if err != nil && os.IsNotExist(err) { + err = os.Mkdir(subpath, 0o755) + if err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + err = os.Chown(subpath, uid, gid) + if err != nil { + return fmt.Errorf("failed to chown directory: %w", err) + } + + continue + } + + if !info.IsDir() { + return fmt.Errorf("path is a file: %s", subpath) + } + } + + return nil +} diff --git a/envd/internal/permissions/user.go b/envd/internal/permissions/user.go new file mode 100644 index 0000000..ead7a5d --- /dev/null +++ b/envd/internal/permissions/user.go @@ -0,0 +1,44 @@ +package permissions + +import ( + "fmt" + "os/user" + "strconv" +) + +func GetUserIdUints(u *user.User) (uid, gid uint32, err error) { + newUID, err := strconv.ParseUint(u.Uid, 10, 32) + if err != nil { + return 0, 0, fmt.Errorf("error parsing uid '%s': %w", u.Uid, err) + } + + newGID, err := strconv.ParseUint(u.Gid, 10, 32) + if err != nil { + return 0, 0, fmt.Errorf("error parsing gid '%s': %w", u.Gid, err) + } + + return uint32(newUID), uint32(newGID), nil +} + +func GetUserIdInts(u *user.User) (uid, gid int, err error) { + newUID, err := strconv.ParseInt(u.Uid, 10, strconv.IntSize) + if err != nil { + return 0, 0, fmt.Errorf("error parsing uid '%s': %w", u.Uid, err) + } + + newGID, err := strconv.ParseInt(u.Gid, 10, strconv.IntSize) + if err != nil { + return 0, 0, fmt.Errorf("error parsing gid '%s': %w", u.Gid, err) + } + + return int(newUID), int(newGID), nil +} + +func GetUser(username string) (u *user.User, err error) { + u, err = user.Lookup(username) + if err != nil { + return nil, fmt.Errorf("error looking up user '%s': %w", username, err) + } + + return u, nil +} diff --git a/envd/internal/port/forward.go b/envd/internal/port/forward.go new file mode 100644 index 0000000..11020f4 --- /dev/null +++ b/envd/internal/port/forward.go @@ -0,0 +1,218 @@ +// portf (port forward) periodaically scans opened TCP ports on the 127.0.0.1 (or localhost) +// and launches `socat` process for every such port in the background. +// socat forward traffic from `sourceIP`:port to the 127.0.0.1:port. + +// WARNING: portf isn't thread safe! + +package port + +import ( + "context" + "fmt" + "net" + "os/exec" + "syscall" + + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/services/cgroups" +) + +type PortState string + +const ( + PortStateForward PortState = "FORWARD" + PortStateDelete PortState = "DELETE" +) + +var defaultGatewayIP = net.IPv4(169, 254, 0, 21) + +type PortToForward struct { + socat *exec.Cmd + // Process ID of the process that's listening on port. + pid int32 + // family version of the ip. + family uint32 + state PortState + port uint32 +} + +type Forwarder struct { + logger *zerolog.Logger + cgroupManager cgroups.Manager + // Map of ports that are being currently forwarded. + ports map[string]*PortToForward + scannerSubscriber *ScannerSubscriber + sourceIP net.IP +} + +func NewForwarder( + logger *zerolog.Logger, + scanner *Scanner, + cgroupManager cgroups.Manager, +) *Forwarder { + scannerSub := scanner.AddSubscriber( + logger, + "port-forwarder", + // We only want to forward ports that are actively listening on localhost. + &ScannerFilter{ + IPs: []string{"127.0.0.1", "localhost", "::1"}, + State: "LISTEN", + }, + ) + + return &Forwarder{ + logger: logger, + sourceIP: defaultGatewayIP, + ports: make(map[string]*PortToForward), + scannerSubscriber: scannerSub, + cgroupManager: cgroupManager, + } +} + +func (f *Forwarder) StartForwarding(ctx context.Context) { + if f.scannerSubscriber == nil { + f.logger.Error().Msg("Cannot start forwarding because scanner subscriber is nil") + + return + } + + for { + // procs is an array of currently opened ports. + if procs, ok := <-f.scannerSubscriber.Messages; ok { + // Now we are going to refresh all ports that are being forwarded in the `ports` map. Maybe add new ones + // and maybe remove some. + + // Go through the ports that are currently being forwarded and set all of them + // to the `DELETE` state. We don't know yet if they will be there after refresh. + for _, v := range f.ports { + v.state = PortStateDelete + } + + // Let's refresh our map of currently forwarded ports and mark the currently opened ones with the "FORWARD" state. + // This will make sure we won't delete them later. + for _, p := range procs { + key := fmt.Sprintf("%d-%d", p.Pid, p.Laddr.Port) + + // We check if the opened port is in our map of forwarded ports. + val, portOk := f.ports[key] + if portOk { + // Just mark the port as being forwarded so we don't delete it. + // The actual socat process that handles forwarding should be running from the last iteration. + val.state = PortStateForward + } else { + f.logger.Debug(). + Str("ip", p.Laddr.IP). + Uint32("port", p.Laddr.Port). + Uint32("family", familyToIPVersion(p.Family)). + Str("state", p.Status). + Msg("Detected new opened port on localhost that is not forwarded") + + // The opened port wasn't in the map so we create a new PortToForward and start forwarding. + ptf := &PortToForward{ + pid: p.Pid, + port: p.Laddr.Port, + state: PortStateForward, + family: familyToIPVersion(p.Family), + } + f.ports[key] = ptf + f.startPortForwarding(ctx, ptf) + } + } + + // We go through the ports map one more time and stop forwarding all ports + // that stayed marked as "DELETE". + for _, v := range f.ports { + if v.state == PortStateDelete { + f.stopPortForwarding(v) + } + } + } + } +} + +func (f *Forwarder) startPortForwarding(ctx context.Context, p *PortToForward) { + // https://unix.stackexchange.com/questions/311492/redirect-application-listening-on-localhost-to-listening-on-external-interface + // socat -d -d TCP4-LISTEN:4000,bind=169.254.0.21,fork TCP4:localhost:4000 + // reuseaddr is used to fix the "Address already in use" error when restarting socat quickly. + cmd := exec.CommandContext(ctx, + "socat", "-d", "-d", "-d", + fmt.Sprintf("TCP4-LISTEN:%v,bind=%s,reuseaddr,fork", p.port, f.sourceIP.To4()), + fmt.Sprintf("TCP%d:localhost:%v", p.family, p.port), + ) + + cgroupFD, ok := f.cgroupManager.GetFileDescriptor(cgroups.ProcessTypeSocat) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + CgroupFD: cgroupFD, + UseCgroupFD: ok, + } + + f.logger.Debug(). + Str("socatCmd", cmd.String()). + Int32("pid", p.pid). + Uint32("family", p.family). + IPAddr("sourceIP", f.sourceIP.To4()). + Uint32("port", p.port). + Msg("About to start port forwarding") + + if err := cmd.Start(); err != nil { + f.logger. + Error(). + Str("socatCmd", cmd.String()). + Err(err). + Msg("Failed to start port forwarding - failed to start socat") + + return + } + + go func() { + if err := cmd.Wait(); err != nil { + f.logger. + Debug(). + Str("socatCmd", cmd.String()). + Err(err). + Msg("Port forwarding socat process exited") + } + }() + + p.socat = cmd +} + +func (f *Forwarder) stopPortForwarding(p *PortToForward) { + if p.socat == nil { + return + } + + defer func() { p.socat = nil }() + + logger := f.logger.With(). + Str("socatCmd", p.socat.String()). + Int32("pid", p.pid). + Uint32("family", p.family). + IPAddr("sourceIP", f.sourceIP.To4()). + Uint32("port", p.port). + Logger() + + logger.Debug().Msg("Stopping port forwarding") + + if err := syscall.Kill(-p.socat.Process.Pid, syscall.SIGKILL); err != nil { + logger.Error().Err(err).Msg("Failed to kill process group") + + return + } + + logger.Debug().Msg("Stopped port forwarding") +} + +func familyToIPVersion(family uint32) uint32 { + switch family { + case syscall.AF_INET: + return 4 + case syscall.AF_INET6: + return 6 + default: + return 0 // Unknown or unsupported family + } +} diff --git a/envd/internal/port/scan.go b/envd/internal/port/scan.go new file mode 100644 index 0000000..a9925df --- /dev/null +++ b/envd/internal/port/scan.go @@ -0,0 +1,59 @@ +package port + +import ( + "time" + + "github.com/rs/zerolog" + "github.com/shirou/gopsutil/v4/net" + + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/smap" +) + +type Scanner struct { + Processes chan net.ConnectionStat + scanExit chan struct{} + subs *smap.Map[*ScannerSubscriber] + period time.Duration +} + +func (s *Scanner) Destroy() { + close(s.scanExit) +} + +func NewScanner(period time.Duration) *Scanner { + return &Scanner{ + period: period, + subs: smap.New[*ScannerSubscriber](), + scanExit: make(chan struct{}), + Processes: make(chan net.ConnectionStat), + } +} + +func (s *Scanner) AddSubscriber(logger *zerolog.Logger, id string, filter *ScannerFilter) *ScannerSubscriber { + subscriber := NewScannerSubscriber(logger, id, filter) + s.subs.Insert(id, subscriber) + + return subscriber +} + +func (s *Scanner) Unsubscribe(sub *ScannerSubscriber) { + s.subs.Remove(sub.ID()) + sub.Destroy() +} + +// ScanAndBroadcast starts scanning open TCP ports and broadcasts every open port to all subscribers. +func (s *Scanner) ScanAndBroadcast() { + for { + // tcp monitors both ipv4 and ipv6 connections. + processes, _ := net.Connections("tcp") + for _, sub := range s.subs.Items() { + sub.Signal(processes) + } + select { + case <-s.scanExit: + return + default: + time.Sleep(s.period) + } + } +} diff --git a/envd/internal/port/scanSubscriber.go b/envd/internal/port/scanSubscriber.go new file mode 100644 index 0000000..ca3615b --- /dev/null +++ b/envd/internal/port/scanSubscriber.go @@ -0,0 +1,50 @@ +package port + +import ( + "github.com/rs/zerolog" + "github.com/shirou/gopsutil/v4/net" +) + +// If we want to create a listener/subscriber pattern somewhere else we should move +// from a concrete implementation to combination of generics and interfaces. + +type ScannerSubscriber struct { + logger *zerolog.Logger + filter *ScannerFilter + Messages chan ([]net.ConnectionStat) + id string +} + +func NewScannerSubscriber(logger *zerolog.Logger, id string, filter *ScannerFilter) *ScannerSubscriber { + return &ScannerSubscriber{ + logger: logger, + id: id, + filter: filter, + Messages: make(chan []net.ConnectionStat), + } +} + +func (ss *ScannerSubscriber) ID() string { + return ss.id +} + +func (ss *ScannerSubscriber) Destroy() { + close(ss.Messages) +} + +func (ss *ScannerSubscriber) Signal(proc []net.ConnectionStat) { + // Filter isn't specified. Accept everything. + if ss.filter == nil { + ss.Messages <- proc + } else { + filtered := []net.ConnectionStat{} + for i := range proc { + // We need to access the list directly otherwise there will be implicit memory aliasing + // If the filter matched a process, we will send it to a channel. + if ss.filter.Match(&proc[i]) { + filtered = append(filtered, proc[i]) + } + } + ss.Messages <- filtered + } +} diff --git a/envd/internal/port/scanfilter.go b/envd/internal/port/scanfilter.go new file mode 100644 index 0000000..88914b2 --- /dev/null +++ b/envd/internal/port/scanfilter.go @@ -0,0 +1,27 @@ +package port + +import ( + "slices" + + "github.com/shirou/gopsutil/v4/net" +) + +type ScannerFilter struct { + State string + IPs []string +} + +func (sf *ScannerFilter) Match(proc *net.ConnectionStat) bool { + // Filter is an empty struct. + if sf.State == "" && len(sf.IPs) == 0 { + return false + } + + ipMatch := slices.Contains(sf.IPs, proc.Laddr.IP) + + if ipMatch && sf.State == proc.Status { + return true + } + + return false +} diff --git a/envd/internal/process/.gitkeep b/envd/internal/process/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/envd/internal/server/.gitkeep b/envd/internal/server/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/envd/internal/services/cgroups/cgroup2.go b/envd/internal/services/cgroups/cgroup2.go new file mode 100644 index 0000000..b834bbe --- /dev/null +++ b/envd/internal/services/cgroups/cgroup2.go @@ -0,0 +1,127 @@ +package cgroups + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +type Cgroup2Manager struct { + cgroupFDs map[ProcessType]int +} + +var _ Manager = (*Cgroup2Manager)(nil) + +type cgroup2Config struct { + rootPath string + processTypes map[ProcessType]Cgroup2Config +} + +type Cgroup2ManagerOption func(*cgroup2Config) + +func WithCgroup2RootSysFSPath(path string) Cgroup2ManagerOption { + return func(config *cgroup2Config) { + config.rootPath = path + } +} + +func WithCgroup2ProcessType(processType ProcessType, path string, properties map[string]string) Cgroup2ManagerOption { + return func(config *cgroup2Config) { + if config.processTypes == nil { + config.processTypes = make(map[ProcessType]Cgroup2Config) + } + config.processTypes[processType] = Cgroup2Config{Path: path, Properties: properties} + } +} + +type Cgroup2Config struct { + Path string + Properties map[string]string +} + +func NewCgroup2Manager(opts ...Cgroup2ManagerOption) (*Cgroup2Manager, error) { + config := cgroup2Config{ + rootPath: "/sys/fs/cgroup", + } + + for _, opt := range opts { + opt(&config) + } + + cgroupFDs, err := createCgroups(config) + if err != nil { + return nil, fmt.Errorf("failed to create cgroups: %w", err) + } + + return &Cgroup2Manager{cgroupFDs: cgroupFDs}, nil +} + +func createCgroups(configs cgroup2Config) (map[ProcessType]int, error) { + var ( + results = make(map[ProcessType]int) + errs []error + ) + + for procType, config := range configs.processTypes { + fullPath := filepath.Join(configs.rootPath, config.Path) + fd, err := createCgroup(fullPath, config.Properties) + if err != nil { + errs = append(errs, fmt.Errorf("failed to create %s cgroup: %w", procType, err)) + + continue + } + results[procType] = fd + } + + if len(errs) > 0 { + for procType, fd := range results { + err := unix.Close(fd) + if err != nil { + errs = append(errs, fmt.Errorf("failed to close cgroup fd for %s: %w", procType, err)) + } + } + + return nil, errors.Join(errs...) + } + + return results, nil +} + +func createCgroup(fullPath string, properties map[string]string) (int, error) { + if err := os.MkdirAll(fullPath, 0o755); err != nil { + return -1, fmt.Errorf("failed to create cgroup root: %w", err) + } + + var errs []error + for name, value := range properties { + if err := os.WriteFile(filepath.Join(fullPath, name), []byte(value), 0o644); err != nil { + errs = append(errs, fmt.Errorf("failed to write cgroup property: %w", err)) + } + } + if len(errs) > 0 { + return -1, errors.Join(errs...) + } + + return unix.Open(fullPath, unix.O_RDONLY, 0) +} + +func (c Cgroup2Manager) GetFileDescriptor(procType ProcessType) (int, bool) { + fd, ok := c.cgroupFDs[procType] + + return fd, ok +} + +func (c Cgroup2Manager) Close() error { + var errs []error + for procType, fd := range c.cgroupFDs { + if err := unix.Close(fd); err != nil { + errs = append(errs, fmt.Errorf("failed to close cgroup fd for %s: %w", procType, err)) + } + delete(c.cgroupFDs, procType) + } + + return errors.Join(errs...) +} diff --git a/envd/internal/services/cgroups/cgroup2_test.go b/envd/internal/services/cgroups/cgroup2_test.go new file mode 100644 index 0000000..a7085d2 --- /dev/null +++ b/envd/internal/services/cgroups/cgroup2_test.go @@ -0,0 +1,185 @@ +package cgroups + +import ( + "context" + "fmt" + "math/rand" + "os" + "os/exec" + "strconv" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + oneByte = 1 + kilobyte = 1024 * oneByte + megabyte = 1024 * kilobyte +) + +func TestCgroupRoundTrip(t *testing.T) { + t.Parallel() + + if os.Geteuid() != 0 { + t.Skip("must run as root") + + return + } + + maxTimeout := time.Second * 5 + + t.Run("process does not die without cgroups", func(t *testing.T) { + t.Parallel() + + // create manager + m, err := NewCgroup2Manager() + require.NoError(t, err) + + // create new child process + cmd := startProcess(t, m, "not-a-real-one") + + // wait for child process to die + err = waitForProcess(t, cmd, maxTimeout) + + require.ErrorIs(t, err, context.DeadlineExceeded) + }) + + t.Run("process dies with cgroups", func(t *testing.T) { + t.Parallel() + + cgroupPath := createCgroupPath(t, "real-one") + + // create manager + m, err := NewCgroup2Manager( + WithCgroup2ProcessType(ProcessTypePTY, cgroupPath, map[string]string{ + "memory.max": strconv.Itoa(1 * megabyte), + }), + ) + require.NoError(t, err) + + t.Cleanup(func() { + err := m.Close() + assert.NoError(t, err) + }) + + // create new child process + cmd := startProcess(t, m, ProcessTypePTY) + + // wait for child process to die + err = waitForProcess(t, cmd, maxTimeout) + + // verify process exited correctly + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, "signal: killed", exitErr.Error()) + assert.False(t, exitErr.Exited()) + assert.False(t, exitErr.Success()) + assert.Equal(t, -1, exitErr.ExitCode()) + + // dig a little deeper + ws, ok := exitErr.Sys().(syscall.WaitStatus) + require.True(t, ok) + assert.Equal(t, syscall.SIGKILL, ws.Signal()) + assert.True(t, ws.Signaled()) + assert.False(t, ws.Stopped()) + assert.False(t, ws.Continued()) + assert.False(t, ws.CoreDump()) + assert.False(t, ws.Exited()) + assert.Equal(t, -1, ws.ExitStatus()) + }) + + t.Run("process cannot be spawned because memory limit is too low", func(t *testing.T) { + t.Parallel() + + cgroupPath := createCgroupPath(t, "real-one") + + // create manager + m, err := NewCgroup2Manager( + WithCgroup2ProcessType(ProcessTypeSocat, cgroupPath, map[string]string{ + "memory.max": strconv.Itoa(1 * kilobyte), + }), + ) + require.NoError(t, err) + + t.Cleanup(func() { + err := m.Close() + assert.NoError(t, err) + }) + + // create new child process + cmd := startProcess(t, m, ProcessTypeSocat) + + // wait for child process to die + err = waitForProcess(t, cmd, maxTimeout) + + // verify process exited correctly + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, "exit status 253", exitErr.Error()) + assert.True(t, exitErr.Exited()) + assert.False(t, exitErr.Success()) + assert.Equal(t, 253, exitErr.ExitCode()) + + // dig a little deeper + ws, ok := exitErr.Sys().(syscall.WaitStatus) + require.True(t, ok) + assert.Equal(t, syscall.Signal(-1), ws.Signal()) + assert.False(t, ws.Signaled()) + assert.False(t, ws.Stopped()) + assert.False(t, ws.Continued()) + assert.False(t, ws.CoreDump()) + assert.True(t, ws.Exited()) + assert.Equal(t, 253, ws.ExitStatus()) + }) +} + +func createCgroupPath(t *testing.T, s string) string { + t.Helper() + + randPart := rand.Int() + + return fmt.Sprintf("envd-test-%s-%d", s, randPart) +} + +func startProcess(t *testing.T, m *Cgroup2Manager, pt ProcessType) *exec.Cmd { + t.Helper() + + cmdName, args := "bash", []string{"-c", `sleep 1 && tail /dev/zero`} + cmd := exec.CommandContext(t.Context(), cmdName, args...) + + fd, ok := m.GetFileDescriptor(pt) + cmd.SysProcAttr = &syscall.SysProcAttr{ + UseCgroupFD: ok, + CgroupFD: fd, + } + + err := cmd.Start() + require.NoError(t, err) + + return cmd +} + +func waitForProcess(t *testing.T, cmd *exec.Cmd, timeout time.Duration) error { + t.Helper() + + done := make(chan error, 1) + + go func() { + defer close(done) + done <- cmd.Wait() + }() + + ctx, cancel := context.WithTimeout(t.Context(), timeout) + t.Cleanup(cancel) + + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + return err + } +} diff --git a/envd/internal/services/cgroups/iface.go b/envd/internal/services/cgroups/iface.go new file mode 100644 index 0000000..103c5bb --- /dev/null +++ b/envd/internal/services/cgroups/iface.go @@ -0,0 +1,14 @@ +package cgroups + +type ProcessType string + +const ( + ProcessTypePTY ProcessType = "pty" + ProcessTypeUser ProcessType = "user" + ProcessTypeSocat ProcessType = "socat" +) + +type Manager interface { + GetFileDescriptor(procType ProcessType) (int, bool) + Close() error +} diff --git a/envd/internal/services/cgroups/noop.go b/envd/internal/services/cgroups/noop.go new file mode 100644 index 0000000..55bf014 --- /dev/null +++ b/envd/internal/services/cgroups/noop.go @@ -0,0 +1,17 @@ +package cgroups + +type NoopManager struct{} + +var _ Manager = (*NoopManager)(nil) + +func NewNoopManager() *NoopManager { + return &NoopManager{} +} + +func (n NoopManager) GetFileDescriptor(ProcessType) (int, bool) { + return 0, false +} + +func (n NoopManager) Close() error { + return nil +} diff --git a/envd/internal/services/filesystem/dir.go b/envd/internal/services/filesystem/dir.go new file mode 100644 index 0000000..f6ed0f3 --- /dev/null +++ b/envd/internal/services/filesystem/dir.go @@ -0,0 +1,184 @@ +package filesystem + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func (s Service) ListDir(ctx context.Context, req *connect.Request[rpc.ListDirRequest]) (*connect.Response[rpc.ListDirResponse], error) { + depth := req.Msg.GetDepth() + if depth == 0 { + depth = 1 // default depth to current directory + } + + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return nil, err + } + + requestedPath := req.Msg.GetPath() + + // Expand the path so we can return absolute paths in the response. + requestedPath, err = permissions.ExpandAndResolve(requestedPath, u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + resolvedPath, err := followSymlink(requestedPath) + if err != nil { + return nil, err + } + + err = checkIfDirectory(resolvedPath) + if err != nil { + return nil, err + } + + entries, err := walkDir(requestedPath, resolvedPath, int(depth)) + if err != nil { + return nil, err + } + + return connect.NewResponse(&rpc.ListDirResponse{ + Entries: entries, + }), nil +} + +func (s Service) MakeDir(ctx context.Context, req *connect.Request[rpc.MakeDirRequest]) (*connect.Response[rpc.MakeDirResponse], error) { + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return nil, err + } + + dirPath, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + stat, err := os.Stat(dirPath) + if err != nil && !os.IsNotExist(err) { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error getting file info: %w", err)) + } + + if err == nil { + if stat.IsDir() { + return nil, connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("directory already exists: %s", dirPath)) + } + + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path already exists but it is not a directory: %s", dirPath)) + } + + uid, gid, userErr := permissions.GetUserIdInts(u) + if userErr != nil { + return nil, connect.NewError(connect.CodeInternal, userErr) + } + + userErr = permissions.EnsureDirs(dirPath, uid, gid) + if userErr != nil { + return nil, connect.NewError(connect.CodeInternal, userErr) + } + + entry, err := entryInfo(dirPath) + if err != nil { + return nil, err + } + + return connect.NewResponse(&rpc.MakeDirResponse{ + Entry: entry, + }), nil +} + +// followSymlink resolves a symbolic link to its target path. +func followSymlink(path string) (string, error) { + // Resolve symlinks + resolvedPath, err := filepath.EvalSymlinks(path) + if err != nil { + if os.IsNotExist(err) { + return "", connect.NewError(connect.CodeNotFound, fmt.Errorf("path not found: %w", err)) + } + + if strings.Contains(err.Error(), "too many links") { + return "", connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("cyclic symlink or chain >255 links at %q", path)) + } + + return "", connect.NewError(connect.CodeInternal, fmt.Errorf("error resolving symlink: %w", err)) + } + + return resolvedPath, nil +} + +// checkIfDirectory checks if the given path is a directory. +func checkIfDirectory(path string) error { + stat, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return connect.NewError(connect.CodeNotFound, fmt.Errorf("directory not found: %w", err)) + } + + return connect.NewError(connect.CodeInternal, fmt.Errorf("error getting file info: %w", err)) + } + + if !stat.IsDir() { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path is not a directory: %s", path)) + } + + return nil +} + +// walkDir walks the directory tree starting from dirPath up to the specified depth (doesn't follow symlinks). +func walkDir(requestedPath string, dirPath string, depth int) (entries []*rpc.EntryInfo, err error) { + err = filepath.WalkDir(dirPath, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip the root directory itself + if path == dirPath { + return nil + } + + // Calculate current depth + relPath, err := filepath.Rel(dirPath, path) + if err != nil { + return err + } + currentDepth := len(strings.Split(relPath, string(os.PathSeparator))) + + if currentDepth > depth { + return filepath.SkipDir + } + + entryInfo, err := entryInfo(path) + if err != nil { + var connectErr *connect.Error + if errors.As(err, &connectErr) && connectErr.Code() == connect.CodeNotFound { + // Skip entries that don't exist anymore + return nil + } + + return err + } + + // Return the requested path as the base path instead of the symlink-resolved path + path = filepath.Join(requestedPath, relPath) + entryInfo.Path = path + + entries = append(entries, entryInfo) + + return nil + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error reading directory %s: %w", dirPath, err)) + } + + return entries, nil +} diff --git a/envd/internal/services/filesystem/dir_test.go b/envd/internal/services/filesystem/dir_test.go new file mode 100644 index 0000000..1c065f7 --- /dev/null +++ b/envd/internal/services/filesystem/dir_test.go @@ -0,0 +1,405 @@ +package filesystem + +import ( + "context" + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "testing" + + "connectrpc.com/authn" + "connectrpc.com/connect" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func TestListDir(t *testing.T) { + t.Parallel() + + // Setup temp root and user + root := t.TempDir() + u, err := user.Current() + require.NoError(t, err) + + // Setup directory structure + testFolder := filepath.Join(root, "test") + require.NoError(t, os.MkdirAll(filepath.Join(testFolder, "test-dir", "sub-dir-1"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(testFolder, "test-dir", "sub-dir-2"), 0o755)) + filePath := filepath.Join(testFolder, "test-dir", "sub-dir-1", "file.txt") + require.NoError(t, os.WriteFile(filePath, []byte("Hello, World!"), 0o644)) + + // Service instance + svc := mockService() + + // Helper to inject user into context + injectUser := func(ctx context.Context, u *user.User) context.Context { + return authn.SetInfo(ctx, u) + } + + tests := []struct { + name string + depth uint32 + expectedPaths []string + }{ + { + name: "depth 0 lists only root directory", + depth: 0, + expectedPaths: []string{ + filepath.Join(testFolder, "test-dir"), + }, + }, + { + name: "depth 1 lists root directory", + depth: 1, + expectedPaths: []string{ + filepath.Join(testFolder, "test-dir"), + }, + }, + { + name: "depth 2 lists first level of subdirectories (in this case the root directory)", + depth: 2, + expectedPaths: []string{ + filepath.Join(testFolder, "test-dir"), + filepath.Join(testFolder, "test-dir", "sub-dir-1"), + filepath.Join(testFolder, "test-dir", "sub-dir-2"), + }, + }, + { + name: "depth 3 lists all directories and files", + depth: 3, + expectedPaths: []string{ + filepath.Join(testFolder, "test-dir"), + filepath.Join(testFolder, "test-dir", "sub-dir-1"), + filepath.Join(testFolder, "test-dir", "sub-dir-2"), + filepath.Join(testFolder, "test-dir", "sub-dir-1", "file.txt"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := injectUser(t.Context(), u) + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: testFolder, + Depth: tt.depth, + }) + resp, err := svc.ListDir(ctx, req) + require.NoError(t, err) + assert.NotEmpty(t, resp.Msg) + assert.Len(t, resp.Msg.GetEntries(), len(tt.expectedPaths)) + actualPaths := make([]string, len(resp.Msg.GetEntries())) + for i, entry := range resp.Msg.GetEntries() { + actualPaths[i] = entry.GetPath() + } + assert.ElementsMatch(t, tt.expectedPaths, actualPaths) + }) + } +} + +func TestListDirNonExistingPath(t *testing.T) { + t.Parallel() + + svc := mockService() + u, err := user.Current() + require.NoError(t, err) + ctx := authn.SetInfo(t.Context(), u) + + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: "/non-existing-path", + Depth: 1, + }) + _, err = svc.ListDir(ctx, req) + require.Error(t, err) + var connectErr *connect.Error + ok := errors.As(err, &connectErr) + assert.True(t, ok, "expected error to be of type *connect.Error") + assert.Equal(t, connect.CodeNotFound, connectErr.Code()) +} + +func TestListDirRelativePath(t *testing.T) { + t.Parallel() + + // Setup temp root and user + u, err := user.Current() + require.NoError(t, err) + + // Setup directory structure + testRelativePath := fmt.Sprintf("test-%s", uuid.New()) + testFolderPath := filepath.Join(u.HomeDir, testRelativePath) + filePath := filepath.Join(testFolderPath, "file.txt") + require.NoError(t, os.MkdirAll(testFolderPath, 0o755)) + require.NoError(t, os.WriteFile(filePath, []byte("Hello, World!"), 0o644)) + + // Service instance + svc := mockService() + ctx := authn.SetInfo(t.Context(), u) + + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: testRelativePath, + Depth: 1, + }) + resp, err := svc.ListDir(ctx, req) + require.NoError(t, err) + assert.NotEmpty(t, resp.Msg) + + expectedPaths := []string{ + filepath.Join(testFolderPath, "file.txt"), + } + assert.Len(t, resp.Msg.GetEntries(), len(expectedPaths)) + + actualPaths := make([]string, len(resp.Msg.GetEntries())) + for i, entry := range resp.Msg.GetEntries() { + actualPaths[i] = entry.GetPath() + } + assert.ElementsMatch(t, expectedPaths, actualPaths) +} + +func TestListDir_Symlinks(t *testing.T) { + t.Parallel() + + root := t.TempDir() + u, err := user.Current() + require.NoError(t, err) + ctx := authn.SetInfo(t.Context(), u) + + symlinkRoot := filepath.Join(root, "test-symlinks") + require.NoError(t, os.MkdirAll(symlinkRoot, 0o755)) + + // 1. Prepare a real directory + file that a symlink will point to + realDir := filepath.Join(symlinkRoot, "real-dir") + require.NoError(t, os.MkdirAll(realDir, 0o755)) + filePath := filepath.Join(realDir, "file.txt") + require.NoError(t, os.WriteFile(filePath, []byte("hello via symlink"), 0o644)) + + // 2. Prepare a standalone real file (points-to-file scenario) + realFile := filepath.Join(symlinkRoot, "real-file.txt") + require.NoError(t, os.WriteFile(realFile, []byte("i am a plain file"), 0o644)) + + // 3. Create the three symlinks + linkToDir := filepath.Join(symlinkRoot, "link-dir") // → directory + linkToFile := filepath.Join(symlinkRoot, "link-file") // → file + cyclicLink := filepath.Join(symlinkRoot, "cyclic") // → itself + require.NoError(t, os.Symlink(realDir, linkToDir)) + require.NoError(t, os.Symlink(realFile, linkToFile)) + require.NoError(t, os.Symlink(cyclicLink, cyclicLink)) + + svc := mockService() + + t.Run("symlink to directory behaves like directory and the content looks like inside the directory", func(t *testing.T) { + t.Parallel() + + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: linkToDir, + Depth: 1, + }) + resp, err := svc.ListDir(ctx, req) + require.NoError(t, err) + expected := []string{ + filepath.Join(linkToDir, "file.txt"), + } + actual := make([]string, len(resp.Msg.GetEntries())) + for i, e := range resp.Msg.GetEntries() { + actual[i] = e.GetPath() + } + assert.ElementsMatch(t, expected, actual) + }) + + t.Run("link to file", func(t *testing.T) { + t.Parallel() + + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: linkToFile, + Depth: 1, + }) + _, err := svc.ListDir(ctx, req) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a directory") + }) + + t.Run("cyclic symlink surfaces 'too many links' → invalid-argument", func(t *testing.T) { + t.Parallel() + + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: cyclicLink, + }) + _, err := svc.ListDir(ctx, req) + require.Error(t, err) + var connectErr *connect.Error + ok := errors.As(err, &connectErr) + assert.True(t, ok, "expected error to be of type *connect.Error") + assert.Equal(t, connect.CodeFailedPrecondition, connectErr.Code()) + assert.Contains(t, connectErr.Error(), "cyclic symlink") + }) + + t.Run("symlink not resolved if not root", func(t *testing.T) { + t.Parallel() + + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: symlinkRoot, + Depth: 3, + }) + res, err := svc.ListDir(ctx, req) + require.NoError(t, err) + expected := []string{ + filepath.Join(symlinkRoot, "cyclic"), + filepath.Join(symlinkRoot, "link-dir"), + filepath.Join(symlinkRoot, "link-file"), + filepath.Join(symlinkRoot, "real-dir"), + filepath.Join(symlinkRoot, "real-dir", "file.txt"), + filepath.Join(symlinkRoot, "real-file.txt"), + } + actual := make([]string, len(res.Msg.GetEntries())) + for i, e := range res.Msg.GetEntries() { + actual[i] = e.GetPath() + } + assert.ElementsMatch(t, expected, actual, "symlinks should not be resolved when listing the symlink root directory") + }) +} + +// TestFollowSymlink_Success makes sure that followSymlink resolves symlinks, +// while also being robust to the /var → /private/var indirection that exists on macOS. +func TestFollowSymlink_Success(t *testing.T) { + t.Parallel() + + // Base temporary directory. On macOS this lives under /var/folders/… + // which itself is a symlink to /private/var/folders/…. + base := t.TempDir() + + // Create a real directory that we ultimately want to resolve to. + target := filepath.Join(base, "target") + require.NoError(t, os.MkdirAll(target, 0o755)) + + // Create a symlink pointing at the real directory so we can verify that + // followSymlink follows it. + link := filepath.Join(base, "link") + require.NoError(t, os.Symlink(target, link)) + + got, err := followSymlink(link) + require.NoError(t, err) + + // Canonicalise the expected path too, so that /var → /private/var (macOS) + // or any other benign symlink indirections don’t cause flaky tests. + want, err := filepath.EvalSymlinks(link) + require.NoError(t, err) + + require.Equal(t, want, got, "followSymlink should resolve and canonicalise symlinks") +} + +// TestFollowSymlink_MultiSymlinkChain verifies that followSymlink follows a chain +// of several symlinks (non‑cyclic) correctly. +func TestFollowSymlink_MultiSymlinkChain(t *testing.T) { + t.Parallel() + + base := t.TempDir() + + // Final destination directory. + target := filepath.Join(base, "target") + require.NoError(t, os.MkdirAll(target, 0o755)) + + // Build a 3‑link chain: link1 → link2 → link3 → target. + link3 := filepath.Join(base, "link3") + require.NoError(t, os.Symlink(target, link3)) + + link2 := filepath.Join(base, "link2") + require.NoError(t, os.Symlink(link3, link2)) + + link1 := filepath.Join(base, "link1") + require.NoError(t, os.Symlink(link2, link1)) + + got, err := followSymlink(link1) + require.NoError(t, err) + + want, err := filepath.EvalSymlinks(link1) + require.NoError(t, err) + + require.Equal(t, want, got, "followSymlink should resolve an arbitrary symlink chain") +} + +func TestFollowSymlink_NotFound(t *testing.T) { + t.Parallel() + + _, err := followSymlink("/definitely/does/not/exist") + require.Error(t, err) + + var cerr *connect.Error + require.ErrorAs(t, err, &cerr) + require.Equal(t, connect.CodeNotFound, cerr.Code()) +} + +func TestFollowSymlink_CyclicSymlink(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + a := filepath.Join(dir, "a") + b := filepath.Join(dir, "b") + require.NoError(t, os.MkdirAll(a, 0o755)) + require.NoError(t, os.MkdirAll(b, 0o755)) + + // Create a two‑node loop: a/loop → b/loop, b/loop → a/loop. + require.NoError(t, os.Symlink(filepath.Join(b, "loop"), filepath.Join(a, "loop"))) + require.NoError(t, os.Symlink(filepath.Join(a, "loop"), filepath.Join(b, "loop"))) + + _, err := followSymlink(filepath.Join(a, "loop")) + require.Error(t, err) + + var cerr *connect.Error + require.ErrorAs(t, err, &cerr) + require.Equal(t, connect.CodeFailedPrecondition, cerr.Code()) + require.Contains(t, cerr.Message(), "cyclic") +} + +func TestCheckIfDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, checkIfDirectory(dir)) + + file := filepath.Join(dir, "file.txt") + require.NoError(t, os.WriteFile(file, []byte("hello"), 0o644)) + + err := checkIfDirectory(file) + require.Error(t, err) + + var cerr *connect.Error + require.ErrorAs(t, err, &cerr) + require.Equal(t, connect.CodeInvalidArgument, cerr.Code()) +} + +func TestWalkDir_Depth(t *testing.T) { + t.Parallel() + + root := t.TempDir() + sub := filepath.Join(root, "sub") + subsub := filepath.Join(sub, "subsub") + require.NoError(t, os.MkdirAll(subsub, 0o755)) + + entries, err := walkDir(root, root, 1) + require.NoError(t, err) + + // Collect the names for easier assertions. + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.GetName()) + } + + require.Contains(t, names, "sub") + require.NotContains(t, names, "subsub", "entries beyond depth should be excluded") +} + +func TestWalkDir_Error(t *testing.T) { + t.Parallel() + + _, err := walkDir("/does/not/exist", "/does/not/exist", 1) + require.Error(t, err) + + var cerr *connect.Error + require.ErrorAs(t, err, &cerr) + require.Equal(t, connect.CodeInternal, cerr.Code()) +} diff --git a/envd/internal/services/filesystem/move.go b/envd/internal/services/filesystem/move.go new file mode 100644 index 0000000..accfc73 --- /dev/null +++ b/envd/internal/services/filesystem/move.go @@ -0,0 +1,58 @@ +package filesystem + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func (s Service) Move(ctx context.Context, req *connect.Request[rpc.MoveRequest]) (*connect.Response[rpc.MoveResponse], error) { + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return nil, err + } + + source, err := permissions.ExpandAndResolve(req.Msg.GetSource(), u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + destination, err := permissions.ExpandAndResolve(req.Msg.GetDestination(), u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + uid, gid, userErr := permissions.GetUserIdInts(u) + if userErr != nil { + return nil, connect.NewError(connect.CodeInternal, userErr) + } + + userErr = permissions.EnsureDirs(filepath.Dir(destination), uid, gid) + if userErr != nil { + return nil, connect.NewError(connect.CodeInternal, userErr) + } + + err = os.Rename(source, destination) + if err != nil { + if os.IsNotExist(err) { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("source file not found: %w", err)) + } + + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error renaming: %w", err)) + } + + entry, err := entryInfo(destination) + if err != nil { + return nil, err + } + + return connect.NewResponse(&rpc.MoveResponse{ + Entry: entry, + }), nil +} diff --git a/envd/internal/services/filesystem/move_test.go b/envd/internal/services/filesystem/move_test.go new file mode 100644 index 0000000..5d2197c --- /dev/null +++ b/envd/internal/services/filesystem/move_test.go @@ -0,0 +1,364 @@ +package filesystem + +import ( + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "testing" + + "connectrpc.com/authn" + "connectrpc.com/connect" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func TestMove(t *testing.T) { + t.Parallel() + + // Setup temp root and user + root := t.TempDir() + u, err := user.Current() + require.NoError(t, err) + + // Setup source and destination directories + sourceDir := filepath.Join(root, "source") + destDir := filepath.Join(root, "destination") + require.NoError(t, os.MkdirAll(sourceDir, 0o755)) + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + // Create a test file to move + sourceFile := filepath.Join(sourceDir, "test-file.txt") + testContent := []byte("Hello, World!") + require.NoError(t, os.WriteFile(sourceFile, testContent, 0o644)) + + // Destination file path + destFile := filepath.Join(destDir, "test-file.txt") + + // Service instance + svc := mockService() + + // Call the Move function + ctx := authn.SetInfo(t.Context(), u) + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: sourceFile, + Destination: destFile, + }) + resp, err := svc.Move(ctx, req) + + // Verify the move was successful + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, destFile, resp.Msg.GetEntry().GetPath()) + + // Verify the file exists at the destination + _, err = os.Stat(destFile) + require.NoError(t, err) + + // Verify the file no longer exists at the source + _, err = os.Stat(sourceFile) + assert.True(t, os.IsNotExist(err)) + + // Verify the content of the moved file + content, err := os.ReadFile(destFile) + require.NoError(t, err) + assert.Equal(t, testContent, content) +} + +func TestMoveDirectory(t *testing.T) { + t.Parallel() + + // Setup temp root and user + root := t.TempDir() + u, err := user.Current() + require.NoError(t, err) + + // Setup source and destination directories + sourceParent := filepath.Join(root, "source-parent") + destParent := filepath.Join(root, "dest-parent") + require.NoError(t, os.MkdirAll(sourceParent, 0o755)) + require.NoError(t, os.MkdirAll(destParent, 0o755)) + + // Create a test directory with files to move + sourceDir := filepath.Join(sourceParent, "test-dir") + require.NoError(t, os.MkdirAll(filepath.Join(sourceDir, "subdir"), 0o755)) + + // Create some files in the directory + file1 := filepath.Join(sourceDir, "file1.txt") + file2 := filepath.Join(sourceDir, "subdir", "file2.txt") + require.NoError(t, os.WriteFile(file1, []byte("File 1 content"), 0o644)) + require.NoError(t, os.WriteFile(file2, []byte("File 2 content"), 0o644)) + + // Destination directory path + destDir := filepath.Join(destParent, "test-dir") + + // Service instance + svc := mockService() + + // Call the Move function + ctx := authn.SetInfo(t.Context(), u) + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: sourceDir, + Destination: destDir, + }) + resp, err := svc.Move(ctx, req) + + // Verify the move was successful + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, destDir, resp.Msg.GetEntry().GetPath()) + + // Verify the directory exists at the destination + _, err = os.Stat(destDir) + require.NoError(t, err) + + // Verify the files exist at the destination + destFile1 := filepath.Join(destDir, "file1.txt") + destFile2 := filepath.Join(destDir, "subdir", "file2.txt") + _, err = os.Stat(destFile1) + require.NoError(t, err) + _, err = os.Stat(destFile2) + require.NoError(t, err) + + // Verify the directory no longer exists at the source + _, err = os.Stat(sourceDir) + assert.True(t, os.IsNotExist(err)) + + // Verify the content of the moved files + content1, err := os.ReadFile(destFile1) + require.NoError(t, err) + assert.Equal(t, []byte("File 1 content"), content1) + + content2, err := os.ReadFile(destFile2) + require.NoError(t, err) + assert.Equal(t, []byte("File 2 content"), content2) +} + +func TestMoveNonExistingFile(t *testing.T) { + t.Parallel() + + // Setup temp root and user + root := t.TempDir() + u, err := user.Current() + require.NoError(t, err) + + // Setup destination directory + destDir := filepath.Join(root, "destination") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + // Non-existing source file + sourceFile := filepath.Join(root, "non-existing-file.txt") + + // Destination file path + destFile := filepath.Join(destDir, "moved-file.txt") + + // Service instance + svc := mockService() + + // Call the Move function + ctx := authn.SetInfo(t.Context(), u) + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: sourceFile, + Destination: destFile, + }) + _, err = svc.Move(ctx, req) + + // Verify the correct error is returned + require.Error(t, err) + + var connectErr *connect.Error + ok := errors.As(err, &connectErr) + assert.True(t, ok, "expected error to be of type *connect.Error") + assert.Equal(t, connect.CodeNotFound, connectErr.Code()) + assert.Contains(t, connectErr.Message(), "source file not found") +} + +func TestMoveRelativePath(t *testing.T) { + t.Parallel() + + // Setup user + u, err := user.Current() + require.NoError(t, err) + + // Setup directory structure with unique name to avoid conflicts + testRelativePath := fmt.Sprintf("test-move-%s", uuid.New()) + testFolderPath := filepath.Join(u.HomeDir, testRelativePath) + require.NoError(t, os.MkdirAll(testFolderPath, 0o755)) + + // Create a test file to move + sourceFile := filepath.Join(testFolderPath, "source-file.txt") + testContent := []byte("Hello from relative path!") + require.NoError(t, os.WriteFile(sourceFile, testContent, 0o644)) + + // Destination file path (also relative) + destRelativePath := fmt.Sprintf("test-move-dest-%s", uuid.New()) + destFolderPath := filepath.Join(u.HomeDir, destRelativePath) + require.NoError(t, os.MkdirAll(destFolderPath, 0o755)) + destFile := filepath.Join(destFolderPath, "moved-file.txt") + + // Service instance + svc := mockService() + + // Call the Move function with relative paths + ctx := authn.SetInfo(t.Context(), u) + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: filepath.Join(testRelativePath, "source-file.txt"), // Relative path + Destination: filepath.Join(destRelativePath, "moved-file.txt"), // Relative path + }) + resp, err := svc.Move(ctx, req) + + // Verify the move was successful + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, destFile, resp.Msg.GetEntry().GetPath()) + + // Verify the file exists at the destination + _, err = os.Stat(destFile) + require.NoError(t, err) + + // Verify the file no longer exists at the source + _, err = os.Stat(sourceFile) + assert.True(t, os.IsNotExist(err)) + + // Verify the content of the moved file + content, err := os.ReadFile(destFile) + require.NoError(t, err) + assert.Equal(t, testContent, content) + + // Clean up + os.RemoveAll(testFolderPath) + os.RemoveAll(destFolderPath) +} + +func TestMove_Symlinks(t *testing.T) { //nolint:tparallel // this test cannot be executed in parallel + root := t.TempDir() + u, err := user.Current() + require.NoError(t, err) + ctx := authn.SetInfo(t.Context(), u) + + // Setup source and destination directories + sourceRoot := filepath.Join(root, "source") + destRoot := filepath.Join(root, "destination") + require.NoError(t, os.MkdirAll(sourceRoot, 0o755)) + require.NoError(t, os.MkdirAll(destRoot, 0o755)) + + // 1. Prepare a real directory + file that a symlink will point to + realDir := filepath.Join(sourceRoot, "real-dir") + require.NoError(t, os.MkdirAll(realDir, 0o755)) + filePath := filepath.Join(realDir, "file.txt") + require.NoError(t, os.WriteFile(filePath, []byte("hello via symlink"), 0o644)) + + // 2. Prepare a standalone real file (points-to-file scenario) + realFile := filepath.Join(sourceRoot, "real-file.txt") + require.NoError(t, os.WriteFile(realFile, []byte("i am a plain file"), 0o644)) + + // 3. Create symlinks + linkToDir := filepath.Join(sourceRoot, "link-dir") // → directory + linkToFile := filepath.Join(sourceRoot, "link-file") // → file + require.NoError(t, os.Symlink(realDir, linkToDir)) + require.NoError(t, os.Symlink(realFile, linkToFile)) + + svc := mockService() + + t.Run("move symlink to directory", func(t *testing.T) { + t.Parallel() + destPath := filepath.Join(destRoot, "moved-link-dir") + + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: linkToDir, + Destination: destPath, + }) + resp, err := svc.Move(ctx, req) + require.NoError(t, err) + assert.Equal(t, destPath, resp.Msg.GetEntry().GetPath()) + + // Verify the symlink was moved + _, err = os.Stat(destPath) + require.NoError(t, err) + + // Verify it's still a symlink + info, err := os.Lstat(destPath) + require.NoError(t, err) + assert.NotEqual(t, 0, info.Mode()&os.ModeSymlink, "expected a symlink") + + // Verify the symlink target is still correct + target, err := os.Readlink(destPath) + require.NoError(t, err) + assert.Equal(t, realDir, target) + + // Verify the original symlink is gone + _, err = os.Stat(linkToDir) + assert.True(t, os.IsNotExist(err)) + + // Verify the real directory still exists + _, err = os.Stat(realDir) + assert.NoError(t, err) + }) + + t.Run("move symlink to file", func(t *testing.T) { //nolint:paralleltest + destPath := filepath.Join(destRoot, "moved-link-file") + + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: linkToFile, + Destination: destPath, + }) + resp, err := svc.Move(ctx, req) + require.NoError(t, err) + assert.Equal(t, destPath, resp.Msg.GetEntry().GetPath()) + + // Verify the symlink was moved + _, err = os.Stat(destPath) + require.NoError(t, err) + + // Verify it's still a symlink + info, err := os.Lstat(destPath) + require.NoError(t, err) + assert.NotEqual(t, 0, info.Mode()&os.ModeSymlink, "expected a symlink") + + // Verify the symlink target is still correct + target, err := os.Readlink(destPath) + require.NoError(t, err) + assert.Equal(t, realFile, target) + + // Verify the original symlink is gone + _, err = os.Stat(linkToFile) + assert.True(t, os.IsNotExist(err)) + + // Verify the real file still exists + _, err = os.Stat(realFile) + assert.NoError(t, err) + }) + + t.Run("move real file that is target of symlink", func(t *testing.T) { + t.Parallel() + // Create a new symlink to the real file + newLinkToFile := filepath.Join(sourceRoot, "new-link-file") + require.NoError(t, os.Symlink(realFile, newLinkToFile)) + + destPath := filepath.Join(destRoot, "moved-real-file.txt") + + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: realFile, + Destination: destPath, + }) + resp, err := svc.Move(ctx, req) + require.NoError(t, err) + assert.Equal(t, destPath, resp.Msg.GetEntry().GetPath()) + + // Verify the real file was moved + _, err = os.Stat(destPath) + require.NoError(t, err) + + // Verify the original file is gone + _, err = os.Stat(realFile) + assert.True(t, os.IsNotExist(err)) + + // Verify the symlink still exists but now points to a non-existent file + _, err = os.Stat(newLinkToFile) + require.Error(t, err, "symlink should point to non-existent file") + }) +} diff --git a/envd/internal/services/filesystem/remove.go b/envd/internal/services/filesystem/remove.go new file mode 100644 index 0000000..a536479 --- /dev/null +++ b/envd/internal/services/filesystem/remove.go @@ -0,0 +1,31 @@ +package filesystem + +import ( + "context" + "fmt" + "os" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func (s Service) Remove(ctx context.Context, req *connect.Request[rpc.RemoveRequest]) (*connect.Response[rpc.RemoveResponse], error) { + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return nil, err + } + + path, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + err = os.RemoveAll(path) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error removing file or directory: %w", err)) + } + + return connect.NewResponse(&rpc.RemoveResponse{}), nil +} diff --git a/envd/internal/services/filesystem/service.go b/envd/internal/services/filesystem/service.go new file mode 100644 index 0000000..3b02f88 --- /dev/null +++ b/envd/internal/services/filesystem/service.go @@ -0,0 +1,34 @@ +package filesystem + +import ( + "connectrpc.com/connect" + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + spec "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem/filesystemconnect" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +type Service struct { + logger *zerolog.Logger + watchers *utils.Map[string, *FileWatcher] + defaults *execcontext.Defaults +} + +func Handle(server *chi.Mux, l *zerolog.Logger, defaults *execcontext.Defaults) { + service := Service{ + logger: l, + watchers: utils.NewMap[string, *FileWatcher](), + defaults: defaults, + } + + interceptors := connect.WithInterceptors( + logs.NewUnaryLogInterceptor(l), + ) + + path, handler := spec.NewFilesystemHandler(service, interceptors) + + server.Mount(path, handler) +} diff --git a/envd/internal/services/filesystem/service_test.go b/envd/internal/services/filesystem/service_test.go new file mode 100644 index 0000000..6629535 --- /dev/null +++ b/envd/internal/services/filesystem/service_test.go @@ -0,0 +1,14 @@ +package filesystem + +import ( + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +func mockService() Service { + return Service{ + defaults: &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + }, + } +} diff --git a/envd/internal/services/filesystem/stat.go b/envd/internal/services/filesystem/stat.go new file mode 100644 index 0000000..b29be22 --- /dev/null +++ b/envd/internal/services/filesystem/stat.go @@ -0,0 +1,29 @@ +package filesystem + +import ( + "context" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func (s Service) Stat(ctx context.Context, req *connect.Request[rpc.StatRequest]) (*connect.Response[rpc.StatResponse], error) { + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return nil, err + } + + path, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + entry, err := entryInfo(path) + if err != nil { + return nil, err + } + + return connect.NewResponse(&rpc.StatResponse{Entry: entry}), nil +} diff --git a/envd/internal/services/filesystem/stat_test.go b/envd/internal/services/filesystem/stat_test.go new file mode 100644 index 0000000..42029e1 --- /dev/null +++ b/envd/internal/services/filesystem/stat_test.go @@ -0,0 +1,114 @@ +package filesystem + +import ( + "context" + "os" + "os/user" + "path/filepath" + "testing" + + "connectrpc.com/authn" + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func TestStat(t *testing.T) { + t.Parallel() + + // Setup temp root and user + root := t.TempDir() + // Get the actual path to the temp directory (symlinks can cause issues) + root, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + + u, err := user.Current() + require.NoError(t, err) + + group, err := user.LookupGroupId(u.Gid) + require.NoError(t, err) + + // Setup directory structure + testFolder := filepath.Join(root, "test") + err = os.MkdirAll(testFolder, 0o755) + require.NoError(t, err) + + testFile := filepath.Join(testFolder, "file.txt") + err = os.WriteFile(testFile, []byte("Hello, World!"), 0o644) + require.NoError(t, err) + + linkedFile := filepath.Join(testFolder, "linked-file.txt") + err = os.Symlink(testFile, linkedFile) + require.NoError(t, err) + + // Service instance + svc := mockService() + + // Helper to inject user into context + injectUser := func(ctx context.Context, u *user.User) context.Context { + return authn.SetInfo(ctx, u) + } + + tests := []struct { + name string + path string + }{ + { + name: "Stat file directory", + path: testFile, + }, + { + name: "Stat symlink to file", + path: linkedFile, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := injectUser(t.Context(), u) + req := connect.NewRequest(&filesystem.StatRequest{ + Path: tt.path, + }) + resp, err := svc.Stat(ctx, req) + require.NoError(t, err) + require.NotEmpty(t, resp.Msg) + require.NotNil(t, resp.Msg.GetEntry()) + assert.Equal(t, tt.path, resp.Msg.GetEntry().GetPath()) + assert.Equal(t, filesystem.FileType_FILE_TYPE_FILE, resp.Msg.GetEntry().GetType()) + assert.Equal(t, u.Username, resp.Msg.GetEntry().GetOwner()) + assert.Equal(t, group.Name, resp.Msg.GetEntry().GetGroup()) + assert.Equal(t, uint32(0o644), resp.Msg.GetEntry().GetMode()) + if tt.path == linkedFile { + require.NotNil(t, resp.Msg.GetEntry().GetSymlinkTarget()) + assert.Equal(t, testFile, resp.Msg.GetEntry().GetSymlinkTarget()) + } else { + assert.Empty(t, resp.Msg.GetEntry().GetSymlinkTarget()) + } + }) + } +} + +func TestStatMissingPathReturnsNotFound(t *testing.T) { + t.Parallel() + + u, err := user.Current() + require.NoError(t, err) + + svc := mockService() + ctx := authn.SetInfo(t.Context(), u) + + req := connect.NewRequest(&filesystem.StatRequest{ + Path: filepath.Join(t.TempDir(), "missing.txt"), + }) + + _, err = svc.Stat(ctx, req) + require.Error(t, err) + + var connectErr *connect.Error + require.ErrorAs(t, err, &connectErr) + assert.Equal(t, connect.CodeNotFound, connectErr.Code()) +} diff --git a/envd/internal/services/filesystem/utils.go b/envd/internal/services/filesystem/utils.go new file mode 100644 index 0000000..9ccb7dc --- /dev/null +++ b/envd/internal/services/filesystem/utils.go @@ -0,0 +1,107 @@ +package filesystem + +import ( + "fmt" + "os" + "os/user" + "syscall" + "time" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/filesystem" +) + +// Filesystem magic numbers from Linux kernel (include/uapi/linux/magic.h) +const ( + nfsSuperMagic = 0x6969 + cifsMagic = 0xFF534D42 + smbSuperMagic = 0x517B + smb2MagicNumber = 0xFE534D42 + fuseSuperMagic = 0x65735546 +) + +// IsPathOnNetworkMount checks if the given path is on a network filesystem mount. +// Returns true if the path is on NFS, CIFS, SMB, or FUSE filesystem. +func IsPathOnNetworkMount(path string) (bool, error) { + var statfs syscall.Statfs_t + if err := syscall.Statfs(path, &statfs); err != nil { + return false, fmt.Errorf("failed to statfs %s: %w", path, err) + } + + switch statfs.Type { + case nfsSuperMagic, cifsMagic, smbSuperMagic, smb2MagicNumber, fuseSuperMagic: + return true, nil + default: + return false, nil + } +} + +func entryInfo(path string) (*rpc.EntryInfo, error) { + info, err := filesystem.GetEntryFromPath(path) + if err != nil { + if os.IsNotExist(err) { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("file not found: %w", err)) + } + + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error getting file info: %w", err)) + } + + owner, group := getFileOwnership(info) + + return &rpc.EntryInfo{ + Name: info.Name, + Type: getEntryType(info.Type), + Path: info.Path, + Size: info.Size, + Mode: uint32(info.Mode), + Permissions: info.Permissions, + Owner: owner, + Group: group, + ModifiedTime: toTimestamp(info.ModifiedTime), + SymlinkTarget: info.SymlinkTarget, + }, nil +} + +func toTimestamp(time time.Time) *timestamppb.Timestamp { + if time.IsZero() { + return nil + } + + return timestamppb.New(time) +} + +// getFileOwnership returns the owner and group names for a file. +// If the lookup fails, it returns the numeric UID and GID as strings. +func getFileOwnership(fileInfo filesystem.EntryInfo) (owner, group string) { + // Look up username + owner = fmt.Sprintf("%d", fileInfo.UID) + if u, err := user.LookupId(owner); err == nil { + owner = u.Username + } + + // Look up group name + group = fmt.Sprintf("%d", fileInfo.GID) + if g, err := user.LookupGroupId(group); err == nil { + group = g.Name + } + + return owner, group +} + +// getEntryType determines the type of file entry based on its mode and path. +// If the file is a symlink, it follows the symlink to determine the actual type. +func getEntryType(fileType filesystem.FileType) rpc.FileType { + switch fileType { + case filesystem.FileFileType: + return rpc.FileType_FILE_TYPE_FILE + case filesystem.DirectoryFileType: + return rpc.FileType_FILE_TYPE_DIRECTORY + case filesystem.SymlinkFileType: + return rpc.FileType_FILE_TYPE_SYMLINK + default: + return rpc.FileType_FILE_TYPE_UNSPECIFIED + } +} diff --git a/envd/internal/services/filesystem/utils_test.go b/envd/internal/services/filesystem/utils_test.go new file mode 100644 index 0000000..8b7ca87 --- /dev/null +++ b/envd/internal/services/filesystem/utils_test.go @@ -0,0 +1,149 @@ +package filesystem + +import ( + "context" + "os/exec" + osuser "os/user" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fsmodel "git.omukk.dev/wrenn/sandbox/envd/internal/shared/filesystem" +) + +func TestIsPathOnNetworkMount(t *testing.T) { + t.Parallel() + + // Test with a regular directory (should not be on network mount) + tempDir := t.TempDir() + isNetwork, err := IsPathOnNetworkMount(tempDir) + require.NoError(t, err) + assert.False(t, isNetwork, "temp directory should not be on a network mount") +} + +func TestIsPathOnNetworkMount_FuseMount(t *testing.T) { + t.Parallel() + + // Require bindfs to be available + _, err := exec.LookPath("bindfs") + require.NoError(t, err, "bindfs must be installed for this test") + + // Require fusermount to be available (needed for unmounting) + _, err = exec.LookPath("fusermount") + require.NoError(t, err, "fusermount must be installed for this test") + + // Create source and mount directories + sourceDir := t.TempDir() + mountDir := t.TempDir() + + // Mount sourceDir onto mountDir using bindfs (FUSE) + ctx := context.Background() + cmd := exec.CommandContext(ctx, "bindfs", sourceDir, mountDir) + require.NoError(t, cmd.Run(), "failed to mount bindfs") + + // Ensure we unmount on cleanup + t.Cleanup(func() { + _ = exec.CommandContext(context.Background(), "fusermount", "-u", mountDir).Run() + }) + + // Test that the FUSE mount is detected + isNetwork, err := IsPathOnNetworkMount(mountDir) + require.NoError(t, err) + assert.True(t, isNetwork, "FUSE mount should be detected as network filesystem") + + // Test that the source directory is NOT detected as network mount + isNetworkSource, err := IsPathOnNetworkMount(sourceDir) + require.NoError(t, err) + assert.False(t, isNetworkSource, "source directory should not be detected as network filesystem") +} + +func TestGetFileOwnership_CurrentUser(t *testing.T) { + t.Parallel() + + t.Run("current user", func(t *testing.T) { + t.Parallel() + + // Get current user running the tests + cur, err := osuser.Current() + if err != nil { + t.Skipf("unable to determine current user: %v", err) + } + + // Determine expected owner/group using the same lookup logic + expectedOwner := cur.Uid + if u, err := osuser.LookupId(cur.Uid); err == nil { + expectedOwner = u.Username + } + + expectedGroup := cur.Gid + if g, err := osuser.LookupGroupId(cur.Gid); err == nil { + expectedGroup = g.Name + } + + // Parse UID/GID strings to uint32 for EntryInfo + uid64, err := strconv.ParseUint(cur.Uid, 10, 32) + require.NoError(t, err) + gid64, err := strconv.ParseUint(cur.Gid, 10, 32) + require.NoError(t, err) + + // Build a minimal EntryInfo with current UID/GID + info := fsmodel.EntryInfo{ // from shared pkg + UID: uint32(uid64), + GID: uint32(gid64), + } + + owner, group := getFileOwnership(info) + assert.Equal(t, expectedOwner, owner) + assert.Equal(t, expectedGroup, group) + }) + + t.Run("no user", func(t *testing.T) { + t.Parallel() + + // Find a UID that does not exist on this system + var unknownUIDStr string + for i := 60001; i < 70000; i++ { // search a high range typically unused + idStr := strconv.Itoa(i) + if _, err := osuser.LookupId(idStr); err != nil { + unknownUIDStr = idStr + + break + } + } + if unknownUIDStr == "" { + t.Skip("could not find a non-existent UID in the probed range") + } + + // Find a GID that does not exist on this system + var unknownGIDStr string + for i := 60001; i < 70000; i++ { // search a high range typically unused + idStr := strconv.Itoa(i) + if _, err := osuser.LookupGroupId(idStr); err != nil { + unknownGIDStr = idStr + + break + } + } + if unknownGIDStr == "" { + t.Skip("could not find a non-existent GID in the probed range") + } + + // Parse to uint32 for EntryInfo construction + uid64, err := strconv.ParseUint(unknownUIDStr, 10, 32) + require.NoError(t, err) + gid64, err := strconv.ParseUint(unknownGIDStr, 10, 32) + require.NoError(t, err) + + info := fsmodel.EntryInfo{ + UID: uint32(uid64), + GID: uint32(gid64), + } + + owner, group := getFileOwnership(info) + // Expect numeric fallbacks because lookups should fail for unknown IDs + assert.Equal(t, unknownUIDStr, owner) + assert.Equal(t, unknownGIDStr, group) + }) +} diff --git a/envd/internal/services/filesystem/watch.go b/envd/internal/services/filesystem/watch.go new file mode 100644 index 0000000..d6b957a --- /dev/null +++ b/envd/internal/services/filesystem/watch.go @@ -0,0 +1,159 @@ +package filesystem + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "connectrpc.com/connect" + "github.com/e2b-dev/fsnotify" + + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +func (s Service) WatchDir(ctx context.Context, req *connect.Request[rpc.WatchDirRequest], stream *connect.ServerStream[rpc.WatchDirResponse]) error { + return logs.LogServerStreamWithoutEvents(ctx, s.logger, req, stream, s.watchHandler) +} + +func (s Service) watchHandler(ctx context.Context, req *connect.Request[rpc.WatchDirRequest], stream *connect.ServerStream[rpc.WatchDirResponse]) error { + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return err + } + + watchPath, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir) + if err != nil { + return connect.NewError(connect.CodeInvalidArgument, err) + } + + info, err := os.Stat(watchPath) + if err != nil { + if os.IsNotExist(err) { + return connect.NewError(connect.CodeNotFound, fmt.Errorf("path %s not found: %w", watchPath, err)) + } + + return connect.NewError(connect.CodeInternal, fmt.Errorf("error statting path %s: %w", watchPath, err)) + } + + if !info.IsDir() { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path %s not a directory: %w", watchPath, err)) + } + + // Check if path is on a network filesystem mount + isNetworkMount, err := IsPathOnNetworkMount(watchPath) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error checking mount status: %w", err)) + } + if isNetworkMount { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot watch path on network filesystem: %s", watchPath)) + } + + w, err := fsnotify.NewWatcher() + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error creating watcher: %w", err)) + } + defer w.Close() + + err = w.Add(utils.FsnotifyPath(watchPath, req.Msg.GetRecursive())) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error adding path %s to watcher: %w", watchPath, err)) + } + + err = stream.Send(&rpc.WatchDirResponse{ + Event: &rpc.WatchDirResponse_Start{ + Start: &rpc.WatchDirResponse_StartEvent{}, + }, + }) + if err != nil { + return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending start event: %w", err)) + } + + keepaliveTicker, resetKeepalive := permissions.GetKeepAliveTicker(req) + defer keepaliveTicker.Stop() + + for { + select { + case <-keepaliveTicker.C: + streamErr := stream.Send(&rpc.WatchDirResponse{ + Event: &rpc.WatchDirResponse_Keepalive{ + Keepalive: &rpc.WatchDirResponse_KeepAlive{}, + }, + }) + if streamErr != nil { + return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending keepalive: %w", streamErr)) + } + case <-ctx.Done(): + return ctx.Err() + case chErr, ok := <-w.Errors: + if !ok { + return connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error channel closed")) + } + + return connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error: %w", chErr)) + case e, ok := <-w.Events: + if !ok { + return connect.NewError(connect.CodeInternal, fmt.Errorf("watcher event channel closed")) + } + + // One event can have multiple operations. + ops := []rpc.EventType{} + + if fsnotify.Create.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_CREATE) + } + + if fsnotify.Rename.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_RENAME) + } + + if fsnotify.Chmod.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_CHMOD) + } + + if fsnotify.Write.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_WRITE) + } + + if fsnotify.Remove.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_REMOVE) + } + + for _, op := range ops { + name, nameErr := filepath.Rel(watchPath, e.Name) + if nameErr != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error getting relative path: %w", nameErr)) + } + + filesystemEvent := &rpc.WatchDirResponse_Filesystem{ + Filesystem: &rpc.FilesystemEvent{ + Name: name, + Type: op, + }, + } + + event := &rpc.WatchDirResponse{ + Event: filesystemEvent, + } + + streamErr := stream.Send(event) + + s.logger. + Debug(). + Str("event_type", "filesystem_event"). + Str(string(logs.OperationIDKey), ctx.Value(logs.OperationIDKey).(string)). + Interface("filesystem_event", event). + Msg("Streaming filesystem event") + + if streamErr != nil { + return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending filesystem event: %w", streamErr)) + } + + resetKeepalive() + } + } + } +} diff --git a/envd/internal/services/filesystem/watch_sync.go b/envd/internal/services/filesystem/watch_sync.go new file mode 100644 index 0000000..c08eebe --- /dev/null +++ b/envd/internal/services/filesystem/watch_sync.go @@ -0,0 +1,224 @@ +package filesystem + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + + "connectrpc.com/connect" + "github.com/e2b-dev/fsnotify" + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/id" +) + +type FileWatcher struct { + watcher *fsnotify.Watcher + Events []*rpc.FilesystemEvent + cancel func() + Error error + + Lock sync.Mutex +} + +func CreateFileWatcher(ctx context.Context, watchPath string, recursive bool, operationID string, logger *zerolog.Logger) (*FileWatcher, error) { + w, err := fsnotify.NewWatcher() + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating watcher: %w", err)) + } + + // We don't want to cancel the context when the request is finished + ctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + + err = w.Add(utils.FsnotifyPath(watchPath, recursive)) + if err != nil { + _ = w.Close() + cancel() + + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error adding path %s to watcher: %w", watchPath, err)) + } + fw := &FileWatcher{ + watcher: w, + cancel: cancel, + Events: []*rpc.FilesystemEvent{}, + Error: nil, + } + + go func() { + for { + select { + case <-ctx.Done(): + return + case chErr, ok := <-w.Errors: + if !ok { + fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error channel closed")) + + return + } + + fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error: %w", chErr)) + + return + case e, ok := <-w.Events: + if !ok { + fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("watcher event channel closed")) + + return + } + + // One event can have multiple operations. + ops := []rpc.EventType{} + + if fsnotify.Create.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_CREATE) + } + + if fsnotify.Rename.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_RENAME) + } + + if fsnotify.Chmod.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_CHMOD) + } + + if fsnotify.Write.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_WRITE) + } + + if fsnotify.Remove.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_REMOVE) + } + + for _, op := range ops { + name, nameErr := filepath.Rel(watchPath, e.Name) + if nameErr != nil { + fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("error getting relative path: %w", nameErr)) + + return + } + + fw.Lock.Lock() + fw.Events = append(fw.Events, &rpc.FilesystemEvent{ + Name: name, + Type: op, + }) + fw.Lock.Unlock() + + // these are only used for logging + filesystemEvent := &rpc.WatchDirResponse_Filesystem{ + Filesystem: &rpc.FilesystemEvent{ + Name: name, + Type: op, + }, + } + event := &rpc.WatchDirResponse{ + Event: filesystemEvent, + } + + logger. + Debug(). + Str("event_type", "filesystem_event"). + Str(string(logs.OperationIDKey), operationID). + Interface("filesystem_event", event). + Msg("Streaming filesystem event") + } + } + } + }() + + return fw, nil +} + +func (fw *FileWatcher) Close() { + _ = fw.watcher.Close() + fw.cancel() +} + +func (s Service) CreateWatcher(ctx context.Context, req *connect.Request[rpc.CreateWatcherRequest]) (*connect.Response[rpc.CreateWatcherResponse], error) { + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return nil, err + } + + watchPath, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + info, err := os.Stat(watchPath) + if err != nil { + if os.IsNotExist(err) { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("path %s not found: %w", watchPath, err)) + } + + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error statting path %s: %w", watchPath, err)) + } + + if !info.IsDir() { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path %s not a directory: %w", watchPath, err)) + } + + // Check if path is on a network filesystem mount + isNetworkMount, err := IsPathOnNetworkMount(watchPath) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error checking mount status: %w", err)) + } + if isNetworkMount { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot watch path on network filesystem: %s", watchPath)) + } + + watcherId := "w" + id.Generate() + + w, err := CreateFileWatcher(ctx, watchPath, req.Msg.GetRecursive(), watcherId, s.logger) + if err != nil { + return nil, err + } + + s.watchers.Store(watcherId, w) + + return connect.NewResponse(&rpc.CreateWatcherResponse{ + WatcherId: watcherId, + }), nil +} + +func (s Service) GetWatcherEvents(_ context.Context, req *connect.Request[rpc.GetWatcherEventsRequest]) (*connect.Response[rpc.GetWatcherEventsResponse], error) { + watcherId := req.Msg.GetWatcherId() + + w, ok := s.watchers.Load(watcherId) + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("watcher with id %s not found", watcherId)) + } + + if w.Error != nil { + return nil, w.Error + } + + w.Lock.Lock() + defer w.Lock.Unlock() + events := w.Events + w.Events = []*rpc.FilesystemEvent{} + + return connect.NewResponse(&rpc.GetWatcherEventsResponse{ + Events: events, + }), nil +} + +func (s Service) RemoveWatcher(_ context.Context, req *connect.Request[rpc.RemoveWatcherRequest]) (*connect.Response[rpc.RemoveWatcherResponse], error) { + watcherId := req.Msg.GetWatcherId() + + w, ok := s.watchers.Load(watcherId) + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("watcher with id %s not found", watcherId)) + } + + w.Close() + s.watchers.Delete(watcherId) + + return connect.NewResponse(&rpc.RemoveWatcherResponse{}), nil +} diff --git a/envd/internal/services/process/connect.go b/envd/internal/services/process/connect.go new file mode 100644 index 0000000..b495406 --- /dev/null +++ b/envd/internal/services/process/connect.go @@ -0,0 +1,126 @@ +package process + +import ( + "context" + "errors" + "fmt" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +func (s *Service) Connect(ctx context.Context, req *connect.Request[rpc.ConnectRequest], stream *connect.ServerStream[rpc.ConnectResponse]) error { + return logs.LogServerStreamWithoutEvents(ctx, s.logger, req, stream, s.handleConnect) +} + +func (s *Service) handleConnect(ctx context.Context, req *connect.Request[rpc.ConnectRequest], stream *connect.ServerStream[rpc.ConnectResponse]) error { + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + + proc, err := s.getProcess(req.Msg.GetProcess()) + if err != nil { + return err + } + + exitChan := make(chan struct{}) + + data, dataCancel := proc.DataEvent.Fork() + defer dataCancel() + + end, endCancel := proc.EndEvent.Fork() + defer endCancel() + + streamErr := stream.Send(&rpc.ConnectResponse{ + Event: &rpc.ProcessEvent{ + Event: &rpc.ProcessEvent_Start{ + Start: &rpc.ProcessEvent_StartEvent{ + Pid: proc.Pid(), + }, + }, + }, + }) + if streamErr != nil { + return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending start event: %w", streamErr)) + } + + go func() { + defer close(exitChan) + + keepaliveTicker, resetKeepalive := permissions.GetKeepAliveTicker(req) + defer keepaliveTicker.Stop() + + dataLoop: + for { + select { + case <-keepaliveTicker.C: + streamErr := stream.Send(&rpc.ConnectResponse{ + Event: &rpc.ProcessEvent{ + Event: &rpc.ProcessEvent_Keepalive{ + Keepalive: &rpc.ProcessEvent_KeepAlive{}, + }, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending keepalive: %w", streamErr))) + + return + } + case <-ctx.Done(): + cancel(ctx.Err()) + + return + case event, ok := <-data: + if !ok { + break dataLoop + } + + streamErr := stream.Send(&rpc.ConnectResponse{ + Event: &rpc.ProcessEvent{ + Event: &event, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending data event: %w", streamErr))) + + return + } + + resetKeepalive() + } + } + + select { + case <-ctx.Done(): + cancel(ctx.Err()) + + return + case event, ok := <-end: + if !ok { + cancel(connect.NewError(connect.CodeUnknown, errors.New("end event channel closed before sending end event"))) + + return + } + + streamErr := stream.Send(&rpc.ConnectResponse{ + Event: &rpc.ProcessEvent{ + Event: &event, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending end event: %w", streamErr))) + + return + } + } + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-exitChan: + return nil + } +} diff --git a/envd/internal/services/process/handler/handler.go b/envd/internal/services/process/handler/handler.go new file mode 100644 index 0000000..51b75e4 --- /dev/null +++ b/envd/internal/services/process/handler/handler.go @@ -0,0 +1,478 @@ +package handler + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/user" + "strconv" + "strings" + "sync" + "syscall" + + "connectrpc.com/connect" + "github.com/creack/pty" + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + "git.omukk.dev/wrenn/sandbox/envd/internal/services/cgroups" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +const ( + defaultNice = 0 + defaultOomScore = 100 + outputBufferSize = 64 + stdChunkSize = 2 << 14 + ptyChunkSize = 2 << 13 +) + +type ProcessExit struct { + Error *string + Status string + Exited bool + Code int32 +} + +type Handler struct { + Config *rpc.ProcessConfig + + logger *zerolog.Logger + + Tag *string + cmd *exec.Cmd + tty *os.File + + cancel context.CancelFunc + + outCtx context.Context //nolint:containedctx // todo: refactor so this can be removed + outCancel context.CancelFunc + + stdinMu sync.Mutex + stdin io.WriteCloser + + DataEvent *MultiplexedChannel[rpc.ProcessEvent_Data] + EndEvent *MultiplexedChannel[rpc.ProcessEvent_End] +} + +// This method must be called only after the process has been started +func (p *Handler) Pid() uint32 { + return uint32(p.cmd.Process.Pid) +} + +// userCommand returns a human-readable representation of the user's original command, +// without the internal OOM/nice wrapper that is prepended to the actual exec. +func (p *Handler) userCommand() string { + return strings.Join(append([]string{p.Config.GetCmd()}, p.Config.GetArgs()...), " ") +} + +// currentNice returns the nice value of the current process. +func currentNice() int { + prio, err := syscall.Getpriority(syscall.PRIO_PROCESS, 0) + if err != nil { + return 0 + } + + // Getpriority returns 20 - nice on Linux. + return 20 - prio +} + +func New( + ctx context.Context, + user *user.User, + req *rpc.StartRequest, + logger *zerolog.Logger, + defaults *execcontext.Defaults, + cgroupManager cgroups.Manager, + cancel context.CancelFunc, +) (*Handler, error) { + // User command string for logging (without the internal wrapper details). + userCmd := strings.Join(append([]string{req.GetProcess().GetCmd()}, req.GetProcess().GetArgs()...), " ") + + // Wrap the command in a shell that sets the OOM score and nice value before exec-ing the actual command. + // This eliminates the race window where grandchildren could inherit the parent's protected OOM score (-1000) + // or high CPU priority (nice -20) before the post-start calls had a chance to correct them. + // nice(1) applies a relative adjustment, so we compute the delta from the current (inherited) nice to the target. + niceDelta := defaultNice - currentNice() + oomWrapperScript := fmt.Sprintf(`echo %d > /proc/$$/oom_score_adj && exec /usr/bin/nice -n %d "${@}"`, defaultOomScore, niceDelta) + wrapperArgs := append([]string{"-c", oomWrapperScript, "--", req.GetProcess().GetCmd()}, req.GetProcess().GetArgs()...) + cmd := exec.CommandContext(ctx, "/bin/sh", wrapperArgs...) + + uid, gid, err := permissions.GetUserIdUints(user) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + groups := []uint32{gid} + if gids, err := user.GroupIds(); err != nil { + logger.Warn().Err(err).Str("user", user.Username).Msg("failed to get supplementary groups") + } else { + for _, g := range gids { + if parsed, err := strconv.ParseUint(g, 10, 32); err == nil { + groups = append(groups, uint32(parsed)) + } + } + } + + cgroupFD, ok := cgroupManager.GetFileDescriptor(getProcType(req)) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + UseCgroupFD: ok, + CgroupFD: cgroupFD, + Credential: &syscall.Credential{ + Uid: uid, + Gid: gid, + Groups: groups, + }, + } + + resolvedPath, err := permissions.ExpandAndResolve(req.GetProcess().GetCwd(), user, defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + // Check if the cwd resolved path exists + if _, err := os.Stat(resolvedPath); errors.Is(err, os.ErrNotExist) { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cwd '%s' does not exist", resolvedPath)) + } + + cmd.Dir = resolvedPath + + var formattedVars []string + + // Take only 'PATH' variable from the current environment + // The 'PATH' should ideally be set in the environment + formattedVars = append(formattedVars, "PATH="+os.Getenv("PATH")) + formattedVars = append(formattedVars, "HOME="+user.HomeDir) + formattedVars = append(formattedVars, "USER="+user.Username) + formattedVars = append(formattedVars, "LOGNAME="+user.Username) + + // Add the environment variables from the global environment + if defaults.EnvVars != nil { + defaults.EnvVars.Range(func(key string, value string) bool { + formattedVars = append(formattedVars, key+"="+value) + + return true + }) + } + + // Only the last values of the env vars are used - this allows for overwriting defaults + for key, value := range req.GetProcess().GetEnvs() { + formattedVars = append(formattedVars, key+"="+value) + } + + cmd.Env = formattedVars + + outMultiplex := NewMultiplexedChannel[rpc.ProcessEvent_Data](outputBufferSize) + + var outWg sync.WaitGroup + + // Create a context for waiting for and cancelling output pipes. + // Cancellation of the process via timeout will propagate and cancel this context too. + outCtx, outCancel := context.WithCancel(ctx) + + h := &Handler{ + Config: req.GetProcess(), + cmd: cmd, + Tag: req.Tag, + DataEvent: outMultiplex, + cancel: cancel, + outCtx: outCtx, + outCancel: outCancel, + EndEvent: NewMultiplexedChannel[rpc.ProcessEvent_End](0), + logger: logger, + } + + if req.GetPty() != nil { + // The pty should ideally start only in the Start method, but the package does not support that and we would have to code it manually. + // The output of the pty should correctly be passed though. + tty, err := pty.StartWithSize(cmd, &pty.Winsize{ + Cols: uint16(req.GetPty().GetSize().GetCols()), + Rows: uint16(req.GetPty().GetSize().GetRows()), + }) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("error starting pty with command '%s' in dir '%s' with '%d' cols and '%d' rows: %w", userCmd, cmd.Dir, req.GetPty().GetSize().GetCols(), req.GetPty().GetSize().GetRows(), err)) + } + + outWg.Go(func() { + for { + buf := make([]byte, ptyChunkSize) + + n, readErr := tty.Read(buf) + + if n > 0 { + outMultiplex.Source <- rpc.ProcessEvent_Data{ + Data: &rpc.ProcessEvent_DataEvent{ + Output: &rpc.ProcessEvent_DataEvent_Pty{ + Pty: buf[:n], + }, + }, + } + } + + if errors.Is(readErr, io.EOF) { + break + } + + if readErr != nil { + fmt.Fprintf(os.Stderr, "error reading from pty: %s\n", readErr) + + break + } + } + }) + + h.tty = tty + } else { + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating stdout pipe for command '%s': %w", userCmd, err)) + } + + outWg.Go(func() { + stdoutLogs := make(chan []byte, outputBufferSize) + defer close(stdoutLogs) + + stdoutLogger := logger.With().Str("event_type", "stdout").Logger() + + go logs.LogBufferedDataEvents(stdoutLogs, &stdoutLogger, "data") + + for { + buf := make([]byte, stdChunkSize) + + n, readErr := stdout.Read(buf) + + if n > 0 { + outMultiplex.Source <- rpc.ProcessEvent_Data{ + Data: &rpc.ProcessEvent_DataEvent{ + Output: &rpc.ProcessEvent_DataEvent_Stdout{ + Stdout: buf[:n], + }, + }, + } + + stdoutLogs <- buf[:n] + } + + if errors.Is(readErr, io.EOF) { + break + } + + if readErr != nil { + fmt.Fprintf(os.Stderr, "error reading from stdout: %s\n", readErr) + + break + } + } + }) + + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating stderr pipe for command '%s': %w", userCmd, err)) + } + + outWg.Go(func() { + stderrLogs := make(chan []byte, outputBufferSize) + defer close(stderrLogs) + + stderrLogger := logger.With().Str("event_type", "stderr").Logger() + + go logs.LogBufferedDataEvents(stderrLogs, &stderrLogger, "data") + + for { + buf := make([]byte, stdChunkSize) + + n, readErr := stderr.Read(buf) + + if n > 0 { + outMultiplex.Source <- rpc.ProcessEvent_Data{ + Data: &rpc.ProcessEvent_DataEvent{ + Output: &rpc.ProcessEvent_DataEvent_Stderr{ + Stderr: buf[:n], + }, + }, + } + + stderrLogs <- buf[:n] + } + + if errors.Is(readErr, io.EOF) { + break + } + + if readErr != nil { + fmt.Fprintf(os.Stderr, "error reading from stderr: %s\n", readErr) + + break + } + } + }) + + // For backwards compatibility we still set the stdin if not explicitly disabled + // If stdin is disabled, the process will use /dev/null as stdin + if req.Stdin == nil || req.GetStdin() == true { + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating stdin pipe for command '%s': %w", userCmd, err)) + } + + h.stdin = stdin + } + } + + go func() { + outWg.Wait() + + close(outMultiplex.Source) + + outCancel() + }() + + return h, nil +} + +func getProcType(req *rpc.StartRequest) cgroups.ProcessType { + if req != nil && req.GetPty() != nil { + return cgroups.ProcessTypePTY + } + + return cgroups.ProcessTypeUser +} + +func (p *Handler) SendSignal(signal syscall.Signal) error { + if p.cmd.Process == nil { + return fmt.Errorf("process not started") + } + + if signal == syscall.SIGKILL || signal == syscall.SIGTERM { + p.outCancel() + } + + return p.cmd.Process.Signal(signal) +} + +func (p *Handler) ResizeTty(size *pty.Winsize) error { + if p.tty == nil { + return fmt.Errorf("tty not assigned to process") + } + + return pty.Setsize(p.tty, size) +} + +func (p *Handler) WriteStdin(data []byte) error { + if p.tty != nil { + return fmt.Errorf("tty assigned to process — input should be written to the pty, not the stdin") + } + + p.stdinMu.Lock() + defer p.stdinMu.Unlock() + + if p.stdin == nil { + return fmt.Errorf("stdin not enabled or closed") + } + + _, err := p.stdin.Write(data) + if err != nil { + return fmt.Errorf("error writing to stdin of process '%d': %w", p.cmd.Process.Pid, err) + } + + return nil +} + +// CloseStdin closes the stdin pipe to signal EOF to the process. +// Only works for non-PTY processes. +func (p *Handler) CloseStdin() error { + if p.tty != nil { + return fmt.Errorf("cannot close stdin for PTY process — send Ctrl+D (0x04) instead") + } + + p.stdinMu.Lock() + defer p.stdinMu.Unlock() + + if p.stdin == nil { + return nil + } + + err := p.stdin.Close() + // We still set the stdin to nil even on error as there are no errors, + // for which it is really safe to retry close across all distributions. + p.stdin = nil + + return err +} + +func (p *Handler) WriteTty(data []byte) error { + if p.tty == nil { + return fmt.Errorf("tty not assigned to process — input should be written to the stdin, not the tty") + } + + _, err := p.tty.Write(data) + if err != nil { + return fmt.Errorf("error writing to tty of process '%d': %w", p.cmd.Process.Pid, err) + } + + return nil +} + +func (p *Handler) Start() (uint32, error) { + // Pty is already started in the New method + if p.tty == nil { + err := p.cmd.Start() + if err != nil { + return 0, fmt.Errorf("error starting process '%s': %w", p.userCommand(), err) + } + } + + p.logger. + Info(). + Str("event_type", "process_start"). + Int("pid", p.cmd.Process.Pid). + Str("command", p.userCommand()). + Msg(fmt.Sprintf("Process with pid %d started", p.cmd.Process.Pid)) + + return uint32(p.cmd.Process.Pid), nil +} + +func (p *Handler) Wait() { + // Wait for the output pipes to be closed or cancelled. + <-p.outCtx.Done() + + err := p.cmd.Wait() + + p.tty.Close() + + var errMsg *string + + if err != nil { + msg := err.Error() + errMsg = &msg + } + + endEvent := &rpc.ProcessEvent_EndEvent{ + Error: errMsg, + ExitCode: int32(p.cmd.ProcessState.ExitCode()), + Exited: p.cmd.ProcessState.Exited(), + Status: p.cmd.ProcessState.String(), + } + + event := rpc.ProcessEvent_End{ + End: endEvent, + } + + p.EndEvent.Source <- event + + p.logger. + Info(). + Str("event_type", "process_end"). + Interface("process_result", endEvent). + Msg(fmt.Sprintf("Process with pid %d ended", p.cmd.Process.Pid)) + + // Ensure the process cancel is called to cleanup resources. + // As it is called after end event and Wait, it should not affect command execution or returned events. + p.cancel() +} diff --git a/envd/internal/services/process/handler/multiplex.go b/envd/internal/services/process/handler/multiplex.go new file mode 100644 index 0000000..4ba16e9 --- /dev/null +++ b/envd/internal/services/process/handler/multiplex.go @@ -0,0 +1,73 @@ +package handler + +import ( + "sync" + "sync/atomic" +) + +type MultiplexedChannel[T any] struct { + Source chan T + channels []chan T + mu sync.RWMutex + exited atomic.Bool +} + +func NewMultiplexedChannel[T any](buffer int) *MultiplexedChannel[T] { + c := &MultiplexedChannel[T]{ + channels: nil, + Source: make(chan T, buffer), + } + + go func() { + for v := range c.Source { + c.mu.RLock() + + for _, cons := range c.channels { + cons <- v + } + + c.mu.RUnlock() + } + + c.exited.Store(true) + + for _, cons := range c.channels { + close(cons) + } + }() + + return c +} + +func (m *MultiplexedChannel[T]) Fork() (chan T, func()) { + if m.exited.Load() { + ch := make(chan T) + close(ch) + + return ch, func() {} + } + + m.mu.Lock() + defer m.mu.Unlock() + + consumer := make(chan T) + + m.channels = append(m.channels, consumer) + + return consumer, func() { + m.remove(consumer) + } +} + +func (m *MultiplexedChannel[T]) remove(consumer chan T) { + m.mu.Lock() + defer m.mu.Unlock() + + for i, ch := range m.channels { + if ch == consumer { + m.channels = append(m.channels[:i], m.channels[i+1:]...) + + return + } + } +} diff --git a/envd/internal/services/process/input.go b/envd/internal/services/process/input.go new file mode 100644 index 0000000..5cb0230 --- /dev/null +++ b/envd/internal/services/process/input.go @@ -0,0 +1,107 @@ +package process + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/services/process/handler" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +func handleInput(ctx context.Context, process *handler.Handler, in *rpc.ProcessInput, logger *zerolog.Logger) error { + switch in.GetInput().(type) { + case *rpc.ProcessInput_Pty: + err := process.WriteTty(in.GetPty()) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error writing to tty: %w", err)) + } + + case *rpc.ProcessInput_Stdin: + err := process.WriteStdin(in.GetStdin()) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error writing to stdin: %w", err)) + } + + logger.Debug(). + Str("event_type", "stdin"). + Interface("stdin", in.GetStdin()). + Str(string(logs.OperationIDKey), ctx.Value(logs.OperationIDKey).(string)). + Msg("Streaming input to process") + + default: + return connect.NewError(connect.CodeUnimplemented, fmt.Errorf("invalid input type %T", in.GetInput())) + } + + return nil +} + +func (s *Service) SendInput(ctx context.Context, req *connect.Request[rpc.SendInputRequest]) (*connect.Response[rpc.SendInputResponse], error) { + proc, err := s.getProcess(req.Msg.GetProcess()) + if err != nil { + return nil, err + } + + err = handleInput(ctx, proc, req.Msg.GetInput(), s.logger) + if err != nil { + return nil, err + } + + return connect.NewResponse(&rpc.SendInputResponse{}), nil +} + +func (s *Service) StreamInput(ctx context.Context, stream *connect.ClientStream[rpc.StreamInputRequest]) (*connect.Response[rpc.StreamInputResponse], error) { + return logs.LogClientStreamWithoutEvents(ctx, s.logger, stream, s.streamInputHandler) +} + +func (s *Service) streamInputHandler(ctx context.Context, stream *connect.ClientStream[rpc.StreamInputRequest]) (*connect.Response[rpc.StreamInputResponse], error) { + var proc *handler.Handler + + for stream.Receive() { + req := stream.Msg() + + switch req.GetEvent().(type) { + case *rpc.StreamInputRequest_Start: + p, err := s.getProcess(req.GetStart().GetProcess()) + if err != nil { + return nil, err + } + + proc = p + case *rpc.StreamInputRequest_Data: + err := handleInput(ctx, proc, req.GetData().GetInput(), s.logger) + if err != nil { + return nil, err + } + case *rpc.StreamInputRequest_Keepalive: + default: + return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("invalid event type %T", req.GetEvent())) + } + } + + err := stream.Err() + if err != nil { + return nil, connect.NewError(connect.CodeUnknown, fmt.Errorf("error streaming input: %w", err)) + } + + return connect.NewResponse(&rpc.StreamInputResponse{}), nil +} + +func (s *Service) CloseStdin( + _ context.Context, + req *connect.Request[rpc.CloseStdinRequest], +) (*connect.Response[rpc.CloseStdinResponse], error) { + handler, err := s.getProcess(req.Msg.GetProcess()) + if err != nil { + return nil, err + } + + if err := handler.CloseStdin(); err != nil { + return nil, connect.NewError(connect.CodeUnknown, fmt.Errorf("error closing stdin: %w", err)) + } + + return connect.NewResponse(&rpc.CloseStdinResponse{}), nil +} diff --git a/envd/internal/services/process/list.go b/envd/internal/services/process/list.go new file mode 100644 index 0000000..1a4a302 --- /dev/null +++ b/envd/internal/services/process/list.go @@ -0,0 +1,28 @@ +package process + +import ( + "context" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/services/process/handler" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +func (s *Service) List(context.Context, *connect.Request[rpc.ListRequest]) (*connect.Response[rpc.ListResponse], error) { + processes := make([]*rpc.ProcessInfo, 0) + + s.processes.Range(func(pid uint32, value *handler.Handler) bool { + processes = append(processes, &rpc.ProcessInfo{ + Pid: pid, + Tag: value.Tag, + Config: value.Config, + }) + + return true + }) + + return connect.NewResponse(&rpc.ListResponse{ + Processes: processes, + }), nil +} diff --git a/envd/internal/services/process/service.go b/envd/internal/services/process/service.go new file mode 100644 index 0000000..7585d02 --- /dev/null +++ b/envd/internal/services/process/service.go @@ -0,0 +1,84 @@ +package process + +import ( + "fmt" + + "connectrpc.com/connect" + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/services/cgroups" + "git.omukk.dev/wrenn/sandbox/envd/internal/services/process/handler" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" + spec "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process/processconnect" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +type Service struct { + processes *utils.Map[uint32, *handler.Handler] + logger *zerolog.Logger + defaults *execcontext.Defaults + cgroupManager cgroups.Manager +} + +func newService(l *zerolog.Logger, defaults *execcontext.Defaults, cgroupManager cgroups.Manager) *Service { + return &Service{ + logger: l, + processes: utils.NewMap[uint32, *handler.Handler](), + defaults: defaults, + cgroupManager: cgroupManager, + } +} + +func Handle(server *chi.Mux, l *zerolog.Logger, defaults *execcontext.Defaults, cgroupManager cgroups.Manager) *Service { + service := newService(l, defaults, cgroupManager) + + interceptors := connect.WithInterceptors(logs.NewUnaryLogInterceptor(l)) + + path, h := spec.NewProcessHandler(service, interceptors) + + server.Mount(path, h) + + return service +} + +func (s *Service) getProcess(selector *rpc.ProcessSelector) (*handler.Handler, error) { + var proc *handler.Handler + + switch selector.GetSelector().(type) { + case *rpc.ProcessSelector_Pid: + p, ok := s.processes.Load(selector.GetPid()) + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("process with pid %d not found", selector.GetPid())) + } + + proc = p + case *rpc.ProcessSelector_Tag: + tag := selector.GetTag() + + s.processes.Range(func(_ uint32, value *handler.Handler) bool { + if value.Tag == nil { + return true + } + + if *value.Tag == tag { + proc = value + + return true + } + + return false + }) + + if proc == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("process with tag %s not found", tag)) + } + + default: + return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("invalid input type %T", selector)) + } + + return proc, nil +} diff --git a/envd/internal/services/process/signal.go b/envd/internal/services/process/signal.go new file mode 100644 index 0000000..ce5ea5d --- /dev/null +++ b/envd/internal/services/process/signal.go @@ -0,0 +1,38 @@ +package process + +import ( + "context" + "fmt" + "syscall" + + "connectrpc.com/connect" + + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +func (s *Service) SendSignal( + _ context.Context, + req *connect.Request[rpc.SendSignalRequest], +) (*connect.Response[rpc.SendSignalResponse], error) { + handler, err := s.getProcess(req.Msg.GetProcess()) + if err != nil { + return nil, err + } + + var signal syscall.Signal + switch req.Msg.GetSignal() { + case rpc.Signal_SIGNAL_SIGKILL: + signal = syscall.SIGKILL + case rpc.Signal_SIGNAL_SIGTERM: + signal = syscall.SIGTERM + default: + return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("invalid signal: %s", req.Msg.GetSignal())) + } + + err = handler.SendSignal(signal) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error sending signal: %w", err)) + } + + return connect.NewResponse(&rpc.SendSignalResponse{}), nil +} diff --git a/envd/internal/services/process/start.go b/envd/internal/services/process/start.go new file mode 100644 index 0000000..75f3ddf --- /dev/null +++ b/envd/internal/services/process/start.go @@ -0,0 +1,247 @@ +package process + +import ( + "context" + "errors" + "fmt" + "net/http" + "os/user" + "strconv" + "time" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + "git.omukk.dev/wrenn/sandbox/envd/internal/services/process/handler" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +func (s *Service) InitializeStartProcess(ctx context.Context, user *user.User, req *rpc.StartRequest) error { + var err error + + ctx = logs.AddRequestIDToContext(ctx) + + defer s.logger. + Err(err). + Interface("request", req). + Str(string(logs.OperationIDKey), ctx.Value(logs.OperationIDKey).(string)). + Msg("Initialized startCmd") + + handlerL := s.logger.With().Str(string(logs.OperationIDKey), ctx.Value(logs.OperationIDKey).(string)).Logger() + + startProcCtx, startProcCancel := context.WithCancel(ctx) + proc, err := handler.New(startProcCtx, user, req, &handlerL, s.defaults, s.cgroupManager, startProcCancel) + if err != nil { + return err + } + + pid, err := proc.Start() + if err != nil { + return err + } + + s.processes.Store(pid, proc) + + go func() { + defer s.processes.Delete(pid) + + proc.Wait() + }() + + return nil +} + +func (s *Service) Start(ctx context.Context, req *connect.Request[rpc.StartRequest], stream *connect.ServerStream[rpc.StartResponse]) error { + return logs.LogServerStreamWithoutEvents(ctx, s.logger, req, stream, s.handleStart) +} + +func (s *Service) handleStart(ctx context.Context, req *connect.Request[rpc.StartRequest], stream *connect.ServerStream[rpc.StartResponse]) error { + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + + handlerL := s.logger.With().Str(string(logs.OperationIDKey), ctx.Value(logs.OperationIDKey).(string)).Logger() + + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return err + } + + timeout, err := determineTimeoutFromHeader(stream.Conn().RequestHeader()) + if err != nil { + return connect.NewError(connect.CodeInvalidArgument, err) + } + + // Create a new context with a timeout if provided. + // We do not want the command to be killed if the request context is cancelled + procCtx, cancelProc := context.Background(), func() {} + if timeout > 0 { // zero timeout means no timeout + procCtx, cancelProc = context.WithTimeout(procCtx, timeout) + } + + proc, err := handler.New( //nolint:contextcheck // TODO: fix this later + procCtx, + u, + req.Msg, + &handlerL, + s.defaults, + s.cgroupManager, + cancelProc, + ) + if err != nil { + // Ensure the process cancel is called to cleanup resources. + cancelProc() + + return err + } + + exitChan := make(chan struct{}) + + startMultiplexer := handler.NewMultiplexedChannel[rpc.ProcessEvent_Start](0) + defer close(startMultiplexer.Source) + + start, startCancel := startMultiplexer.Fork() + defer startCancel() + + data, dataCancel := proc.DataEvent.Fork() + defer dataCancel() + + end, endCancel := proc.EndEvent.Fork() + defer endCancel() + + go func() { + defer close(exitChan) + + select { + case <-ctx.Done(): + cancel(ctx.Err()) + + return + case event, ok := <-start: + if !ok { + cancel(connect.NewError(connect.CodeUnknown, errors.New("start event channel closed before sending start event"))) + + return + } + + streamErr := stream.Send(&rpc.StartResponse{ + Event: &rpc.ProcessEvent{ + Event: &event, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending start event: %w", streamErr))) + + return + } + } + + keepaliveTicker, resetKeepalive := permissions.GetKeepAliveTicker(req) + defer keepaliveTicker.Stop() + + dataLoop: + for { + select { + case <-keepaliveTicker.C: + streamErr := stream.Send(&rpc.StartResponse{ + Event: &rpc.ProcessEvent{ + Event: &rpc.ProcessEvent_Keepalive{ + Keepalive: &rpc.ProcessEvent_KeepAlive{}, + }, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending keepalive: %w", streamErr))) + + return + } + case <-ctx.Done(): + cancel(ctx.Err()) + + return + case event, ok := <-data: + if !ok { + break dataLoop + } + + streamErr := stream.Send(&rpc.StartResponse{ + Event: &rpc.ProcessEvent{ + Event: &event, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending data event: %w", streamErr))) + + return + } + + resetKeepalive() + } + } + + select { + case <-ctx.Done(): + cancel(ctx.Err()) + + return + case event, ok := <-end: + if !ok { + cancel(connect.NewError(connect.CodeUnknown, errors.New("end event channel closed before sending end event"))) + + return + } + + streamErr := stream.Send(&rpc.StartResponse{ + Event: &rpc.ProcessEvent{ + Event: &event, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending end event: %w", streamErr))) + + return + } + } + }() + + pid, err := proc.Start() + if err != nil { + return connect.NewError(connect.CodeInvalidArgument, err) + } + + s.processes.Store(pid, proc) + + start <- rpc.ProcessEvent_Start{ + Start: &rpc.ProcessEvent_StartEvent{ + Pid: pid, + }, + } + + go func() { + defer s.processes.Delete(pid) + + proc.Wait() + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-exitChan: + return nil + } +} + +func determineTimeoutFromHeader(header http.Header) (time.Duration, error) { + timeoutHeader := header.Get("Connect-Timeout-Ms") + + if timeoutHeader == "" { + return 0, nil + } + + timeout, err := strconv.Atoi(timeoutHeader) + if err != nil { + return 0, err + } + + return time.Duration(timeout) * time.Millisecond, nil +} diff --git a/envd/internal/services/process/update.go b/envd/internal/services/process/update.go new file mode 100644 index 0000000..2753e27 --- /dev/null +++ b/envd/internal/services/process/update.go @@ -0,0 +1,30 @@ +package process + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/creack/pty" + + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +func (s *Service) Update(_ context.Context, req *connect.Request[rpc.UpdateRequest]) (*connect.Response[rpc.UpdateResponse], error) { + proc, err := s.getProcess(req.Msg.GetProcess()) + if err != nil { + return nil, err + } + + if req.Msg.GetPty() != nil { + err := proc.ResizeTty(&pty.Winsize{ + Rows: uint16(req.Msg.GetPty().GetSize().GetRows()), + Cols: uint16(req.Msg.GetPty().GetSize().GetCols()), + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error resizing tty: %w", err)) + } + } + + return connect.NewResponse(&rpc.UpdateResponse{}), nil +} diff --git a/envd/internal/services/spec/filesystem/filesystem.pb.go b/envd/internal/services/spec/filesystem/filesystem.pb.go new file mode 100644 index 0000000..d8a03bd --- /dev/null +++ b/envd/internal/services/spec/filesystem/filesystem.pb.go @@ -0,0 +1,1444 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: filesystem/filesystem.proto + +package filesystem + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type FileType int32 + +const ( + FileType_FILE_TYPE_UNSPECIFIED FileType = 0 + FileType_FILE_TYPE_FILE FileType = 1 + FileType_FILE_TYPE_DIRECTORY FileType = 2 + FileType_FILE_TYPE_SYMLINK FileType = 3 +) + +// Enum value maps for FileType. +var ( + FileType_name = map[int32]string{ + 0: "FILE_TYPE_UNSPECIFIED", + 1: "FILE_TYPE_FILE", + 2: "FILE_TYPE_DIRECTORY", + 3: "FILE_TYPE_SYMLINK", + } + FileType_value = map[string]int32{ + "FILE_TYPE_UNSPECIFIED": 0, + "FILE_TYPE_FILE": 1, + "FILE_TYPE_DIRECTORY": 2, + "FILE_TYPE_SYMLINK": 3, + } +) + +func (x FileType) Enum() *FileType { + p := new(FileType) + *p = x + return p +} + +func (x FileType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FileType) Descriptor() protoreflect.EnumDescriptor { + return file_filesystem_filesystem_proto_enumTypes[0].Descriptor() +} + +func (FileType) Type() protoreflect.EnumType { + return &file_filesystem_filesystem_proto_enumTypes[0] +} + +func (x FileType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FileType.Descriptor instead. +func (FileType) EnumDescriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{0} +} + +type EventType int32 + +const ( + EventType_EVENT_TYPE_UNSPECIFIED EventType = 0 + EventType_EVENT_TYPE_CREATE EventType = 1 + EventType_EVENT_TYPE_WRITE EventType = 2 + EventType_EVENT_TYPE_REMOVE EventType = 3 + EventType_EVENT_TYPE_RENAME EventType = 4 + EventType_EVENT_TYPE_CHMOD EventType = 5 +) + +// Enum value maps for EventType. +var ( + EventType_name = map[int32]string{ + 0: "EVENT_TYPE_UNSPECIFIED", + 1: "EVENT_TYPE_CREATE", + 2: "EVENT_TYPE_WRITE", + 3: "EVENT_TYPE_REMOVE", + 4: "EVENT_TYPE_RENAME", + 5: "EVENT_TYPE_CHMOD", + } + EventType_value = map[string]int32{ + "EVENT_TYPE_UNSPECIFIED": 0, + "EVENT_TYPE_CREATE": 1, + "EVENT_TYPE_WRITE": 2, + "EVENT_TYPE_REMOVE": 3, + "EVENT_TYPE_RENAME": 4, + "EVENT_TYPE_CHMOD": 5, + } +) + +func (x EventType) Enum() *EventType { + p := new(EventType) + *p = x + return p +} + +func (x EventType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (EventType) Descriptor() protoreflect.EnumDescriptor { + return file_filesystem_filesystem_proto_enumTypes[1].Descriptor() +} + +func (EventType) Type() protoreflect.EnumType { + return &file_filesystem_filesystem_proto_enumTypes[1] +} + +func (x EventType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use EventType.Descriptor instead. +func (EventType) EnumDescriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{1} +} + +type MoveRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` + Destination string `protobuf:"bytes,2,opt,name=destination,proto3" json:"destination,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MoveRequest) Reset() { + *x = MoveRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MoveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MoveRequest) ProtoMessage() {} + +func (x *MoveRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MoveRequest.ProtoReflect.Descriptor instead. +func (*MoveRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{0} +} + +func (x *MoveRequest) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *MoveRequest) GetDestination() string { + if x != nil { + return x.Destination + } + return "" +} + +type MoveResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *EntryInfo `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MoveResponse) Reset() { + *x = MoveResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MoveResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MoveResponse) ProtoMessage() {} + +func (x *MoveResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MoveResponse.ProtoReflect.Descriptor instead. +func (*MoveResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{1} +} + +func (x *MoveResponse) GetEntry() *EntryInfo { + if x != nil { + return x.Entry + } + return nil +} + +type MakeDirRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MakeDirRequest) Reset() { + *x = MakeDirRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MakeDirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MakeDirRequest) ProtoMessage() {} + +func (x *MakeDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MakeDirRequest.ProtoReflect.Descriptor instead. +func (*MakeDirRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{2} +} + +func (x *MakeDirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type MakeDirResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *EntryInfo `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MakeDirResponse) Reset() { + *x = MakeDirResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MakeDirResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MakeDirResponse) ProtoMessage() {} + +func (x *MakeDirResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MakeDirResponse.ProtoReflect.Descriptor instead. +func (*MakeDirResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{3} +} + +func (x *MakeDirResponse) GetEntry() *EntryInfo { + if x != nil { + return x.Entry + } + return nil +} + +type RemoveRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveRequest) Reset() { + *x = RemoveRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveRequest) ProtoMessage() {} + +func (x *RemoveRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveRequest.ProtoReflect.Descriptor instead. +func (*RemoveRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{4} +} + +func (x *RemoveRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type RemoveResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveResponse) Reset() { + *x = RemoveResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveResponse) ProtoMessage() {} + +func (x *RemoveResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveResponse.ProtoReflect.Descriptor instead. +func (*RemoveResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{5} +} + +type StatRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatRequest) Reset() { + *x = StatRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatRequest) ProtoMessage() {} + +func (x *StatRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatRequest.ProtoReflect.Descriptor instead. +func (*StatRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{6} +} + +func (x *StatRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type StatResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *EntryInfo `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatResponse) Reset() { + *x = StatResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatResponse) ProtoMessage() {} + +func (x *StatResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatResponse.ProtoReflect.Descriptor instead. +func (*StatResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{7} +} + +func (x *StatResponse) GetEntry() *EntryInfo { + if x != nil { + return x.Entry + } + return nil +} + +type EntryInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type FileType `protobuf:"varint,2,opt,name=type,proto3,enum=filesystem.FileType" json:"type,omitempty"` + Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` + Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` + Mode uint32 `protobuf:"varint,5,opt,name=mode,proto3" json:"mode,omitempty"` + Permissions string `protobuf:"bytes,6,opt,name=permissions,proto3" json:"permissions,omitempty"` + Owner string `protobuf:"bytes,7,opt,name=owner,proto3" json:"owner,omitempty"` + Group string `protobuf:"bytes,8,opt,name=group,proto3" json:"group,omitempty"` + ModifiedTime *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=modified_time,json=modifiedTime,proto3" json:"modified_time,omitempty"` + // If the entry is a symlink, this field contains the target of the symlink. + SymlinkTarget *string `protobuf:"bytes,10,opt,name=symlink_target,json=symlinkTarget,proto3,oneof" json:"symlink_target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EntryInfo) Reset() { + *x = EntryInfo{} + mi := &file_filesystem_filesystem_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EntryInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntryInfo) ProtoMessage() {} + +func (x *EntryInfo) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntryInfo.ProtoReflect.Descriptor instead. +func (*EntryInfo) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{8} +} + +func (x *EntryInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *EntryInfo) GetType() FileType { + if x != nil { + return x.Type + } + return FileType_FILE_TYPE_UNSPECIFIED +} + +func (x *EntryInfo) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *EntryInfo) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *EntryInfo) GetMode() uint32 { + if x != nil { + return x.Mode + } + return 0 +} + +func (x *EntryInfo) GetPermissions() string { + if x != nil { + return x.Permissions + } + return "" +} + +func (x *EntryInfo) GetOwner() string { + if x != nil { + return x.Owner + } + return "" +} + +func (x *EntryInfo) GetGroup() string { + if x != nil { + return x.Group + } + return "" +} + +func (x *EntryInfo) GetModifiedTime() *timestamppb.Timestamp { + if x != nil { + return x.ModifiedTime + } + return nil +} + +func (x *EntryInfo) GetSymlinkTarget() string { + if x != nil && x.SymlinkTarget != nil { + return *x.SymlinkTarget + } + return "" +} + +type ListDirRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Depth uint32 `protobuf:"varint,2,opt,name=depth,proto3" json:"depth,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDirRequest) Reset() { + *x = ListDirRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDirRequest) ProtoMessage() {} + +func (x *ListDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDirRequest.ProtoReflect.Descriptor instead. +func (*ListDirRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{9} +} + +func (x *ListDirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *ListDirRequest) GetDepth() uint32 { + if x != nil { + return x.Depth + } + return 0 +} + +type ListDirResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries []*EntryInfo `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDirResponse) Reset() { + *x = ListDirResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDirResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDirResponse) ProtoMessage() {} + +func (x *ListDirResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDirResponse.ProtoReflect.Descriptor instead. +func (*ListDirResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{10} +} + +func (x *ListDirResponse) GetEntries() []*EntryInfo { + if x != nil { + return x.Entries + } + return nil +} + +type WatchDirRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Recursive bool `protobuf:"varint,2,opt,name=recursive,proto3" json:"recursive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirRequest) Reset() { + *x = WatchDirRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirRequest) ProtoMessage() {} + +func (x *WatchDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirRequest.ProtoReflect.Descriptor instead. +func (*WatchDirRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{11} +} + +func (x *WatchDirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *WatchDirRequest) GetRecursive() bool { + if x != nil { + return x.Recursive + } + return false +} + +type FilesystemEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type EventType `protobuf:"varint,2,opt,name=type,proto3,enum=filesystem.EventType" json:"type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesystemEvent) Reset() { + *x = FilesystemEvent{} + mi := &file_filesystem_filesystem_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesystemEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesystemEvent) ProtoMessage() {} + +func (x *FilesystemEvent) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilesystemEvent.ProtoReflect.Descriptor instead. +func (*FilesystemEvent) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{12} +} + +func (x *FilesystemEvent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FilesystemEvent) GetType() EventType { + if x != nil { + return x.Type + } + return EventType_EVENT_TYPE_UNSPECIFIED +} + +type WatchDirResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *WatchDirResponse_Start + // *WatchDirResponse_Filesystem + // *WatchDirResponse_Keepalive + Event isWatchDirResponse_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirResponse) Reset() { + *x = WatchDirResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirResponse) ProtoMessage() {} + +func (x *WatchDirResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirResponse.ProtoReflect.Descriptor instead. +func (*WatchDirResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{13} +} + +func (x *WatchDirResponse) GetEvent() isWatchDirResponse_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *WatchDirResponse) GetStart() *WatchDirResponse_StartEvent { + if x != nil { + if x, ok := x.Event.(*WatchDirResponse_Start); ok { + return x.Start + } + } + return nil +} + +func (x *WatchDirResponse) GetFilesystem() *FilesystemEvent { + if x != nil { + if x, ok := x.Event.(*WatchDirResponse_Filesystem); ok { + return x.Filesystem + } + } + return nil +} + +func (x *WatchDirResponse) GetKeepalive() *WatchDirResponse_KeepAlive { + if x != nil { + if x, ok := x.Event.(*WatchDirResponse_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isWatchDirResponse_Event interface { + isWatchDirResponse_Event() +} + +type WatchDirResponse_Start struct { + Start *WatchDirResponse_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type WatchDirResponse_Filesystem struct { + Filesystem *FilesystemEvent `protobuf:"bytes,2,opt,name=filesystem,proto3,oneof"` +} + +type WatchDirResponse_Keepalive struct { + Keepalive *WatchDirResponse_KeepAlive `protobuf:"bytes,3,opt,name=keepalive,proto3,oneof"` +} + +func (*WatchDirResponse_Start) isWatchDirResponse_Event() {} + +func (*WatchDirResponse_Filesystem) isWatchDirResponse_Event() {} + +func (*WatchDirResponse_Keepalive) isWatchDirResponse_Event() {} + +type CreateWatcherRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Recursive bool `protobuf:"varint,2,opt,name=recursive,proto3" json:"recursive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateWatcherRequest) Reset() { + *x = CreateWatcherRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateWatcherRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateWatcherRequest) ProtoMessage() {} + +func (x *CreateWatcherRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateWatcherRequest.ProtoReflect.Descriptor instead. +func (*CreateWatcherRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{14} +} + +func (x *CreateWatcherRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *CreateWatcherRequest) GetRecursive() bool { + if x != nil { + return x.Recursive + } + return false +} + +type CreateWatcherResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + WatcherId string `protobuf:"bytes,1,opt,name=watcher_id,json=watcherId,proto3" json:"watcher_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateWatcherResponse) Reset() { + *x = CreateWatcherResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateWatcherResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateWatcherResponse) ProtoMessage() {} + +func (x *CreateWatcherResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateWatcherResponse.ProtoReflect.Descriptor instead. +func (*CreateWatcherResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{15} +} + +func (x *CreateWatcherResponse) GetWatcherId() string { + if x != nil { + return x.WatcherId + } + return "" +} + +type GetWatcherEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WatcherId string `protobuf:"bytes,1,opt,name=watcher_id,json=watcherId,proto3" json:"watcher_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetWatcherEventsRequest) Reset() { + *x = GetWatcherEventsRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetWatcherEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetWatcherEventsRequest) ProtoMessage() {} + +func (x *GetWatcherEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetWatcherEventsRequest.ProtoReflect.Descriptor instead. +func (*GetWatcherEventsRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{16} +} + +func (x *GetWatcherEventsRequest) GetWatcherId() string { + if x != nil { + return x.WatcherId + } + return "" +} + +type GetWatcherEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Events []*FilesystemEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetWatcherEventsResponse) Reset() { + *x = GetWatcherEventsResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetWatcherEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetWatcherEventsResponse) ProtoMessage() {} + +func (x *GetWatcherEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetWatcherEventsResponse.ProtoReflect.Descriptor instead. +func (*GetWatcherEventsResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{17} +} + +func (x *GetWatcherEventsResponse) GetEvents() []*FilesystemEvent { + if x != nil { + return x.Events + } + return nil +} + +type RemoveWatcherRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WatcherId string `protobuf:"bytes,1,opt,name=watcher_id,json=watcherId,proto3" json:"watcher_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveWatcherRequest) Reset() { + *x = RemoveWatcherRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveWatcherRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveWatcherRequest) ProtoMessage() {} + +func (x *RemoveWatcherRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveWatcherRequest.ProtoReflect.Descriptor instead. +func (*RemoveWatcherRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{18} +} + +func (x *RemoveWatcherRequest) GetWatcherId() string { + if x != nil { + return x.WatcherId + } + return "" +} + +type RemoveWatcherResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveWatcherResponse) Reset() { + *x = RemoveWatcherResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveWatcherResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveWatcherResponse) ProtoMessage() {} + +func (x *RemoveWatcherResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveWatcherResponse.ProtoReflect.Descriptor instead. +func (*RemoveWatcherResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{19} +} + +type WatchDirResponse_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirResponse_StartEvent) Reset() { + *x = WatchDirResponse_StartEvent{} + mi := &file_filesystem_filesystem_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirResponse_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirResponse_StartEvent) ProtoMessage() {} + +func (x *WatchDirResponse_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirResponse_StartEvent.ProtoReflect.Descriptor instead. +func (*WatchDirResponse_StartEvent) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{13, 0} +} + +type WatchDirResponse_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirResponse_KeepAlive) Reset() { + *x = WatchDirResponse_KeepAlive{} + mi := &file_filesystem_filesystem_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirResponse_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirResponse_KeepAlive) ProtoMessage() {} + +func (x *WatchDirResponse_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirResponse_KeepAlive.ProtoReflect.Descriptor instead. +func (*WatchDirResponse_KeepAlive) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{13, 1} +} + +var File_filesystem_filesystem_proto protoreflect.FileDescriptor + +const file_filesystem_filesystem_proto_rawDesc = "" + + "\n" + + "\x1bfilesystem/filesystem.proto\x12\n" + + "filesystem\x1a\x1fgoogle/protobuf/timestamp.proto\"G\n" + + "\vMoveRequest\x12\x16\n" + + "\x06source\x18\x01 \x01(\tR\x06source\x12 \n" + + "\vdestination\x18\x02 \x01(\tR\vdestination\";\n" + + "\fMoveResponse\x12+\n" + + "\x05entry\x18\x01 \x01(\v2\x15.filesystem.EntryInfoR\x05entry\"$\n" + + "\x0eMakeDirRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\">\n" + + "\x0fMakeDirResponse\x12+\n" + + "\x05entry\x18\x01 \x01(\v2\x15.filesystem.EntryInfoR\x05entry\"#\n" + + "\rRemoveRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"\x10\n" + + "\x0eRemoveResponse\"!\n" + + "\vStatRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\";\n" + + "\fStatResponse\x12+\n" + + "\x05entry\x18\x01 \x01(\v2\x15.filesystem.EntryInfoR\x05entry\"\xd3\x02\n" + + "\tEntryInfo\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12(\n" + + "\x04type\x18\x02 \x01(\x0e2\x14.filesystem.FileTypeR\x04type\x12\x12\n" + + "\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n" + + "\x04size\x18\x04 \x01(\x03R\x04size\x12\x12\n" + + "\x04mode\x18\x05 \x01(\rR\x04mode\x12 \n" + + "\vpermissions\x18\x06 \x01(\tR\vpermissions\x12\x14\n" + + "\x05owner\x18\a \x01(\tR\x05owner\x12\x14\n" + + "\x05group\x18\b \x01(\tR\x05group\x12?\n" + + "\rmodified_time\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\fmodifiedTime\x12*\n" + + "\x0esymlink_target\x18\n" + + " \x01(\tH\x00R\rsymlinkTarget\x88\x01\x01B\x11\n" + + "\x0f_symlink_target\":\n" + + "\x0eListDirRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n" + + "\x05depth\x18\x02 \x01(\rR\x05depth\"B\n" + + "\x0fListDirResponse\x12/\n" + + "\aentries\x18\x01 \x03(\v2\x15.filesystem.EntryInfoR\aentries\"C\n" + + "\x0fWatchDirRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n" + + "\trecursive\x18\x02 \x01(\bR\trecursive\"P\n" + + "\x0fFilesystemEvent\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12)\n" + + "\x04type\x18\x02 \x01(\x0e2\x15.filesystem.EventTypeR\x04type\"\xfe\x01\n" + + "\x10WatchDirResponse\x12?\n" + + "\x05start\x18\x01 \x01(\v2'.filesystem.WatchDirResponse.StartEventH\x00R\x05start\x12=\n" + + "\n" + + "filesystem\x18\x02 \x01(\v2\x1b.filesystem.FilesystemEventH\x00R\n" + + "filesystem\x12F\n" + + "\tkeepalive\x18\x03 \x01(\v2&.filesystem.WatchDirResponse.KeepAliveH\x00R\tkeepalive\x1a\f\n" + + "\n" + + "StartEvent\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"H\n" + + "\x14CreateWatcherRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n" + + "\trecursive\x18\x02 \x01(\bR\trecursive\"6\n" + + "\x15CreateWatcherResponse\x12\x1d\n" + + "\n" + + "watcher_id\x18\x01 \x01(\tR\twatcherId\"8\n" + + "\x17GetWatcherEventsRequest\x12\x1d\n" + + "\n" + + "watcher_id\x18\x01 \x01(\tR\twatcherId\"O\n" + + "\x18GetWatcherEventsResponse\x123\n" + + "\x06events\x18\x01 \x03(\v2\x1b.filesystem.FilesystemEventR\x06events\"5\n" + + "\x14RemoveWatcherRequest\x12\x1d\n" + + "\n" + + "watcher_id\x18\x01 \x01(\tR\twatcherId\"\x17\n" + + "\x15RemoveWatcherResponse*i\n" + + "\bFileType\x12\x19\n" + + "\x15FILE_TYPE_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eFILE_TYPE_FILE\x10\x01\x12\x17\n" + + "\x13FILE_TYPE_DIRECTORY\x10\x02\x12\x15\n" + + "\x11FILE_TYPE_SYMLINK\x10\x03*\x98\x01\n" + + "\tEventType\x12\x1a\n" + + "\x16EVENT_TYPE_UNSPECIFIED\x10\x00\x12\x15\n" + + "\x11EVENT_TYPE_CREATE\x10\x01\x12\x14\n" + + "\x10EVENT_TYPE_WRITE\x10\x02\x12\x15\n" + + "\x11EVENT_TYPE_REMOVE\x10\x03\x12\x15\n" + + "\x11EVENT_TYPE_RENAME\x10\x04\x12\x14\n" + + "\x10EVENT_TYPE_CHMOD\x10\x052\x9f\x05\n" + + "\n" + + "Filesystem\x129\n" + + "\x04Stat\x12\x17.filesystem.StatRequest\x1a\x18.filesystem.StatResponse\x12B\n" + + "\aMakeDir\x12\x1a.filesystem.MakeDirRequest\x1a\x1b.filesystem.MakeDirResponse\x129\n" + + "\x04Move\x12\x17.filesystem.MoveRequest\x1a\x18.filesystem.MoveResponse\x12B\n" + + "\aListDir\x12\x1a.filesystem.ListDirRequest\x1a\x1b.filesystem.ListDirResponse\x12?\n" + + "\x06Remove\x12\x19.filesystem.RemoveRequest\x1a\x1a.filesystem.RemoveResponse\x12G\n" + + "\bWatchDir\x12\x1b.filesystem.WatchDirRequest\x1a\x1c.filesystem.WatchDirResponse0\x01\x12T\n" + + "\rCreateWatcher\x12 .filesystem.CreateWatcherRequest\x1a!.filesystem.CreateWatcherResponse\x12]\n" + + "\x10GetWatcherEvents\x12#.filesystem.GetWatcherEventsRequest\x1a$.filesystem.GetWatcherEventsResponse\x12T\n" + + "\rRemoveWatcher\x12 .filesystem.RemoveWatcherRequest\x1a!.filesystem.RemoveWatcherResponseB\xad\x01\n" + + "\x0ecom.filesystemB\x0fFilesystemProtoP\x01ZBgit.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem\xa2\x02\x03FXX\xaa\x02\n" + + "Filesystem\xca\x02\n" + + "Filesystem\xe2\x02\x16Filesystem\\GPBMetadata\xea\x02\n" + + "Filesystemb\x06proto3" + +var ( + file_filesystem_filesystem_proto_rawDescOnce sync.Once + file_filesystem_filesystem_proto_rawDescData []byte +) + +func file_filesystem_filesystem_proto_rawDescGZIP() []byte { + file_filesystem_filesystem_proto_rawDescOnce.Do(func() { + file_filesystem_filesystem_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_filesystem_filesystem_proto_rawDesc), len(file_filesystem_filesystem_proto_rawDesc))) + }) + return file_filesystem_filesystem_proto_rawDescData +} + +var file_filesystem_filesystem_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_filesystem_filesystem_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_filesystem_filesystem_proto_goTypes = []any{ + (FileType)(0), // 0: filesystem.FileType + (EventType)(0), // 1: filesystem.EventType + (*MoveRequest)(nil), // 2: filesystem.MoveRequest + (*MoveResponse)(nil), // 3: filesystem.MoveResponse + (*MakeDirRequest)(nil), // 4: filesystem.MakeDirRequest + (*MakeDirResponse)(nil), // 5: filesystem.MakeDirResponse + (*RemoveRequest)(nil), // 6: filesystem.RemoveRequest + (*RemoveResponse)(nil), // 7: filesystem.RemoveResponse + (*StatRequest)(nil), // 8: filesystem.StatRequest + (*StatResponse)(nil), // 9: filesystem.StatResponse + (*EntryInfo)(nil), // 10: filesystem.EntryInfo + (*ListDirRequest)(nil), // 11: filesystem.ListDirRequest + (*ListDirResponse)(nil), // 12: filesystem.ListDirResponse + (*WatchDirRequest)(nil), // 13: filesystem.WatchDirRequest + (*FilesystemEvent)(nil), // 14: filesystem.FilesystemEvent + (*WatchDirResponse)(nil), // 15: filesystem.WatchDirResponse + (*CreateWatcherRequest)(nil), // 16: filesystem.CreateWatcherRequest + (*CreateWatcherResponse)(nil), // 17: filesystem.CreateWatcherResponse + (*GetWatcherEventsRequest)(nil), // 18: filesystem.GetWatcherEventsRequest + (*GetWatcherEventsResponse)(nil), // 19: filesystem.GetWatcherEventsResponse + (*RemoveWatcherRequest)(nil), // 20: filesystem.RemoveWatcherRequest + (*RemoveWatcherResponse)(nil), // 21: filesystem.RemoveWatcherResponse + (*WatchDirResponse_StartEvent)(nil), // 22: filesystem.WatchDirResponse.StartEvent + (*WatchDirResponse_KeepAlive)(nil), // 23: filesystem.WatchDirResponse.KeepAlive + (*timestamppb.Timestamp)(nil), // 24: google.protobuf.Timestamp +} +var file_filesystem_filesystem_proto_depIdxs = []int32{ + 10, // 0: filesystem.MoveResponse.entry:type_name -> filesystem.EntryInfo + 10, // 1: filesystem.MakeDirResponse.entry:type_name -> filesystem.EntryInfo + 10, // 2: filesystem.StatResponse.entry:type_name -> filesystem.EntryInfo + 0, // 3: filesystem.EntryInfo.type:type_name -> filesystem.FileType + 24, // 4: filesystem.EntryInfo.modified_time:type_name -> google.protobuf.Timestamp + 10, // 5: filesystem.ListDirResponse.entries:type_name -> filesystem.EntryInfo + 1, // 6: filesystem.FilesystemEvent.type:type_name -> filesystem.EventType + 22, // 7: filesystem.WatchDirResponse.start:type_name -> filesystem.WatchDirResponse.StartEvent + 14, // 8: filesystem.WatchDirResponse.filesystem:type_name -> filesystem.FilesystemEvent + 23, // 9: filesystem.WatchDirResponse.keepalive:type_name -> filesystem.WatchDirResponse.KeepAlive + 14, // 10: filesystem.GetWatcherEventsResponse.events:type_name -> filesystem.FilesystemEvent + 8, // 11: filesystem.Filesystem.Stat:input_type -> filesystem.StatRequest + 4, // 12: filesystem.Filesystem.MakeDir:input_type -> filesystem.MakeDirRequest + 2, // 13: filesystem.Filesystem.Move:input_type -> filesystem.MoveRequest + 11, // 14: filesystem.Filesystem.ListDir:input_type -> filesystem.ListDirRequest + 6, // 15: filesystem.Filesystem.Remove:input_type -> filesystem.RemoveRequest + 13, // 16: filesystem.Filesystem.WatchDir:input_type -> filesystem.WatchDirRequest + 16, // 17: filesystem.Filesystem.CreateWatcher:input_type -> filesystem.CreateWatcherRequest + 18, // 18: filesystem.Filesystem.GetWatcherEvents:input_type -> filesystem.GetWatcherEventsRequest + 20, // 19: filesystem.Filesystem.RemoveWatcher:input_type -> filesystem.RemoveWatcherRequest + 9, // 20: filesystem.Filesystem.Stat:output_type -> filesystem.StatResponse + 5, // 21: filesystem.Filesystem.MakeDir:output_type -> filesystem.MakeDirResponse + 3, // 22: filesystem.Filesystem.Move:output_type -> filesystem.MoveResponse + 12, // 23: filesystem.Filesystem.ListDir:output_type -> filesystem.ListDirResponse + 7, // 24: filesystem.Filesystem.Remove:output_type -> filesystem.RemoveResponse + 15, // 25: filesystem.Filesystem.WatchDir:output_type -> filesystem.WatchDirResponse + 17, // 26: filesystem.Filesystem.CreateWatcher:output_type -> filesystem.CreateWatcherResponse + 19, // 27: filesystem.Filesystem.GetWatcherEvents:output_type -> filesystem.GetWatcherEventsResponse + 21, // 28: filesystem.Filesystem.RemoveWatcher:output_type -> filesystem.RemoveWatcherResponse + 20, // [20:29] is the sub-list for method output_type + 11, // [11:20] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_filesystem_filesystem_proto_init() } +func file_filesystem_filesystem_proto_init() { + if File_filesystem_filesystem_proto != nil { + return + } + file_filesystem_filesystem_proto_msgTypes[8].OneofWrappers = []any{} + file_filesystem_filesystem_proto_msgTypes[13].OneofWrappers = []any{ + (*WatchDirResponse_Start)(nil), + (*WatchDirResponse_Filesystem)(nil), + (*WatchDirResponse_Keepalive)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_filesystem_filesystem_proto_rawDesc), len(file_filesystem_filesystem_proto_rawDesc)), + NumEnums: 2, + NumMessages: 22, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_filesystem_filesystem_proto_goTypes, + DependencyIndexes: file_filesystem_filesystem_proto_depIdxs, + EnumInfos: file_filesystem_filesystem_proto_enumTypes, + MessageInfos: file_filesystem_filesystem_proto_msgTypes, + }.Build() + File_filesystem_filesystem_proto = out.File + file_filesystem_filesystem_proto_goTypes = nil + file_filesystem_filesystem_proto_depIdxs = nil +} diff --git a/envd/internal/services/spec/filesystem/filesystemconnect/filesystem.connect.go b/envd/internal/services/spec/filesystem/filesystemconnect/filesystem.connect.go new file mode 100644 index 0000000..05893f2 --- /dev/null +++ b/envd/internal/services/spec/filesystem/filesystemconnect/filesystem.connect.go @@ -0,0 +1,337 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: filesystem/filesystem.proto + +package filesystemconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + filesystem "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // FilesystemName is the fully-qualified name of the Filesystem service. + FilesystemName = "filesystem.Filesystem" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // FilesystemStatProcedure is the fully-qualified name of the Filesystem's Stat RPC. + FilesystemStatProcedure = "/filesystem.Filesystem/Stat" + // FilesystemMakeDirProcedure is the fully-qualified name of the Filesystem's MakeDir RPC. + FilesystemMakeDirProcedure = "/filesystem.Filesystem/MakeDir" + // FilesystemMoveProcedure is the fully-qualified name of the Filesystem's Move RPC. + FilesystemMoveProcedure = "/filesystem.Filesystem/Move" + // FilesystemListDirProcedure is the fully-qualified name of the Filesystem's ListDir RPC. + FilesystemListDirProcedure = "/filesystem.Filesystem/ListDir" + // FilesystemRemoveProcedure is the fully-qualified name of the Filesystem's Remove RPC. + FilesystemRemoveProcedure = "/filesystem.Filesystem/Remove" + // FilesystemWatchDirProcedure is the fully-qualified name of the Filesystem's WatchDir RPC. + FilesystemWatchDirProcedure = "/filesystem.Filesystem/WatchDir" + // FilesystemCreateWatcherProcedure is the fully-qualified name of the Filesystem's CreateWatcher + // RPC. + FilesystemCreateWatcherProcedure = "/filesystem.Filesystem/CreateWatcher" + // FilesystemGetWatcherEventsProcedure is the fully-qualified name of the Filesystem's + // GetWatcherEvents RPC. + FilesystemGetWatcherEventsProcedure = "/filesystem.Filesystem/GetWatcherEvents" + // FilesystemRemoveWatcherProcedure is the fully-qualified name of the Filesystem's RemoveWatcher + // RPC. + FilesystemRemoveWatcherProcedure = "/filesystem.Filesystem/RemoveWatcher" +) + +// FilesystemClient is a client for the filesystem.Filesystem service. +type FilesystemClient interface { + Stat(context.Context, *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) + MakeDir(context.Context, *connect.Request[filesystem.MakeDirRequest]) (*connect.Response[filesystem.MakeDirResponse], error) + Move(context.Context, *connect.Request[filesystem.MoveRequest]) (*connect.Response[filesystem.MoveResponse], error) + ListDir(context.Context, *connect.Request[filesystem.ListDirRequest]) (*connect.Response[filesystem.ListDirResponse], error) + Remove(context.Context, *connect.Request[filesystem.RemoveRequest]) (*connect.Response[filesystem.RemoveResponse], error) + WatchDir(context.Context, *connect.Request[filesystem.WatchDirRequest]) (*connect.ServerStreamForClient[filesystem.WatchDirResponse], error) + // Non-streaming versions of WatchDir + CreateWatcher(context.Context, *connect.Request[filesystem.CreateWatcherRequest]) (*connect.Response[filesystem.CreateWatcherResponse], error) + GetWatcherEvents(context.Context, *connect.Request[filesystem.GetWatcherEventsRequest]) (*connect.Response[filesystem.GetWatcherEventsResponse], error) + RemoveWatcher(context.Context, *connect.Request[filesystem.RemoveWatcherRequest]) (*connect.Response[filesystem.RemoveWatcherResponse], error) +} + +// NewFilesystemClient constructs a client for the filesystem.Filesystem service. By default, it +// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewFilesystemClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) FilesystemClient { + baseURL = strings.TrimRight(baseURL, "/") + filesystemMethods := filesystem.File_filesystem_filesystem_proto.Services().ByName("Filesystem").Methods() + return &filesystemClient{ + stat: connect.NewClient[filesystem.StatRequest, filesystem.StatResponse]( + httpClient, + baseURL+FilesystemStatProcedure, + connect.WithSchema(filesystemMethods.ByName("Stat")), + connect.WithClientOptions(opts...), + ), + makeDir: connect.NewClient[filesystem.MakeDirRequest, filesystem.MakeDirResponse]( + httpClient, + baseURL+FilesystemMakeDirProcedure, + connect.WithSchema(filesystemMethods.ByName("MakeDir")), + connect.WithClientOptions(opts...), + ), + move: connect.NewClient[filesystem.MoveRequest, filesystem.MoveResponse]( + httpClient, + baseURL+FilesystemMoveProcedure, + connect.WithSchema(filesystemMethods.ByName("Move")), + connect.WithClientOptions(opts...), + ), + listDir: connect.NewClient[filesystem.ListDirRequest, filesystem.ListDirResponse]( + httpClient, + baseURL+FilesystemListDirProcedure, + connect.WithSchema(filesystemMethods.ByName("ListDir")), + connect.WithClientOptions(opts...), + ), + remove: connect.NewClient[filesystem.RemoveRequest, filesystem.RemoveResponse]( + httpClient, + baseURL+FilesystemRemoveProcedure, + connect.WithSchema(filesystemMethods.ByName("Remove")), + connect.WithClientOptions(opts...), + ), + watchDir: connect.NewClient[filesystem.WatchDirRequest, filesystem.WatchDirResponse]( + httpClient, + baseURL+FilesystemWatchDirProcedure, + connect.WithSchema(filesystemMethods.ByName("WatchDir")), + connect.WithClientOptions(opts...), + ), + createWatcher: connect.NewClient[filesystem.CreateWatcherRequest, filesystem.CreateWatcherResponse]( + httpClient, + baseURL+FilesystemCreateWatcherProcedure, + connect.WithSchema(filesystemMethods.ByName("CreateWatcher")), + connect.WithClientOptions(opts...), + ), + getWatcherEvents: connect.NewClient[filesystem.GetWatcherEventsRequest, filesystem.GetWatcherEventsResponse]( + httpClient, + baseURL+FilesystemGetWatcherEventsProcedure, + connect.WithSchema(filesystemMethods.ByName("GetWatcherEvents")), + connect.WithClientOptions(opts...), + ), + removeWatcher: connect.NewClient[filesystem.RemoveWatcherRequest, filesystem.RemoveWatcherResponse]( + httpClient, + baseURL+FilesystemRemoveWatcherProcedure, + connect.WithSchema(filesystemMethods.ByName("RemoveWatcher")), + connect.WithClientOptions(opts...), + ), + } +} + +// filesystemClient implements FilesystemClient. +type filesystemClient struct { + stat *connect.Client[filesystem.StatRequest, filesystem.StatResponse] + makeDir *connect.Client[filesystem.MakeDirRequest, filesystem.MakeDirResponse] + move *connect.Client[filesystem.MoveRequest, filesystem.MoveResponse] + listDir *connect.Client[filesystem.ListDirRequest, filesystem.ListDirResponse] + remove *connect.Client[filesystem.RemoveRequest, filesystem.RemoveResponse] + watchDir *connect.Client[filesystem.WatchDirRequest, filesystem.WatchDirResponse] + createWatcher *connect.Client[filesystem.CreateWatcherRequest, filesystem.CreateWatcherResponse] + getWatcherEvents *connect.Client[filesystem.GetWatcherEventsRequest, filesystem.GetWatcherEventsResponse] + removeWatcher *connect.Client[filesystem.RemoveWatcherRequest, filesystem.RemoveWatcherResponse] +} + +// Stat calls filesystem.Filesystem.Stat. +func (c *filesystemClient) Stat(ctx context.Context, req *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) { + return c.stat.CallUnary(ctx, req) +} + +// MakeDir calls filesystem.Filesystem.MakeDir. +func (c *filesystemClient) MakeDir(ctx context.Context, req *connect.Request[filesystem.MakeDirRequest]) (*connect.Response[filesystem.MakeDirResponse], error) { + return c.makeDir.CallUnary(ctx, req) +} + +// Move calls filesystem.Filesystem.Move. +func (c *filesystemClient) Move(ctx context.Context, req *connect.Request[filesystem.MoveRequest]) (*connect.Response[filesystem.MoveResponse], error) { + return c.move.CallUnary(ctx, req) +} + +// ListDir calls filesystem.Filesystem.ListDir. +func (c *filesystemClient) ListDir(ctx context.Context, req *connect.Request[filesystem.ListDirRequest]) (*connect.Response[filesystem.ListDirResponse], error) { + return c.listDir.CallUnary(ctx, req) +} + +// Remove calls filesystem.Filesystem.Remove. +func (c *filesystemClient) Remove(ctx context.Context, req *connect.Request[filesystem.RemoveRequest]) (*connect.Response[filesystem.RemoveResponse], error) { + return c.remove.CallUnary(ctx, req) +} + +// WatchDir calls filesystem.Filesystem.WatchDir. +func (c *filesystemClient) WatchDir(ctx context.Context, req *connect.Request[filesystem.WatchDirRequest]) (*connect.ServerStreamForClient[filesystem.WatchDirResponse], error) { + return c.watchDir.CallServerStream(ctx, req) +} + +// CreateWatcher calls filesystem.Filesystem.CreateWatcher. +func (c *filesystemClient) CreateWatcher(ctx context.Context, req *connect.Request[filesystem.CreateWatcherRequest]) (*connect.Response[filesystem.CreateWatcherResponse], error) { + return c.createWatcher.CallUnary(ctx, req) +} + +// GetWatcherEvents calls filesystem.Filesystem.GetWatcherEvents. +func (c *filesystemClient) GetWatcherEvents(ctx context.Context, req *connect.Request[filesystem.GetWatcherEventsRequest]) (*connect.Response[filesystem.GetWatcherEventsResponse], error) { + return c.getWatcherEvents.CallUnary(ctx, req) +} + +// RemoveWatcher calls filesystem.Filesystem.RemoveWatcher. +func (c *filesystemClient) RemoveWatcher(ctx context.Context, req *connect.Request[filesystem.RemoveWatcherRequest]) (*connect.Response[filesystem.RemoveWatcherResponse], error) { + return c.removeWatcher.CallUnary(ctx, req) +} + +// FilesystemHandler is an implementation of the filesystem.Filesystem service. +type FilesystemHandler interface { + Stat(context.Context, *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) + MakeDir(context.Context, *connect.Request[filesystem.MakeDirRequest]) (*connect.Response[filesystem.MakeDirResponse], error) + Move(context.Context, *connect.Request[filesystem.MoveRequest]) (*connect.Response[filesystem.MoveResponse], error) + ListDir(context.Context, *connect.Request[filesystem.ListDirRequest]) (*connect.Response[filesystem.ListDirResponse], error) + Remove(context.Context, *connect.Request[filesystem.RemoveRequest]) (*connect.Response[filesystem.RemoveResponse], error) + WatchDir(context.Context, *connect.Request[filesystem.WatchDirRequest], *connect.ServerStream[filesystem.WatchDirResponse]) error + // Non-streaming versions of WatchDir + CreateWatcher(context.Context, *connect.Request[filesystem.CreateWatcherRequest]) (*connect.Response[filesystem.CreateWatcherResponse], error) + GetWatcherEvents(context.Context, *connect.Request[filesystem.GetWatcherEventsRequest]) (*connect.Response[filesystem.GetWatcherEventsResponse], error) + RemoveWatcher(context.Context, *connect.Request[filesystem.RemoveWatcherRequest]) (*connect.Response[filesystem.RemoveWatcherResponse], error) +} + +// NewFilesystemHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewFilesystemHandler(svc FilesystemHandler, opts ...connect.HandlerOption) (string, http.Handler) { + filesystemMethods := filesystem.File_filesystem_filesystem_proto.Services().ByName("Filesystem").Methods() + filesystemStatHandler := connect.NewUnaryHandler( + FilesystemStatProcedure, + svc.Stat, + connect.WithSchema(filesystemMethods.ByName("Stat")), + connect.WithHandlerOptions(opts...), + ) + filesystemMakeDirHandler := connect.NewUnaryHandler( + FilesystemMakeDirProcedure, + svc.MakeDir, + connect.WithSchema(filesystemMethods.ByName("MakeDir")), + connect.WithHandlerOptions(opts...), + ) + filesystemMoveHandler := connect.NewUnaryHandler( + FilesystemMoveProcedure, + svc.Move, + connect.WithSchema(filesystemMethods.ByName("Move")), + connect.WithHandlerOptions(opts...), + ) + filesystemListDirHandler := connect.NewUnaryHandler( + FilesystemListDirProcedure, + svc.ListDir, + connect.WithSchema(filesystemMethods.ByName("ListDir")), + connect.WithHandlerOptions(opts...), + ) + filesystemRemoveHandler := connect.NewUnaryHandler( + FilesystemRemoveProcedure, + svc.Remove, + connect.WithSchema(filesystemMethods.ByName("Remove")), + connect.WithHandlerOptions(opts...), + ) + filesystemWatchDirHandler := connect.NewServerStreamHandler( + FilesystemWatchDirProcedure, + svc.WatchDir, + connect.WithSchema(filesystemMethods.ByName("WatchDir")), + connect.WithHandlerOptions(opts...), + ) + filesystemCreateWatcherHandler := connect.NewUnaryHandler( + FilesystemCreateWatcherProcedure, + svc.CreateWatcher, + connect.WithSchema(filesystemMethods.ByName("CreateWatcher")), + connect.WithHandlerOptions(opts...), + ) + filesystemGetWatcherEventsHandler := connect.NewUnaryHandler( + FilesystemGetWatcherEventsProcedure, + svc.GetWatcherEvents, + connect.WithSchema(filesystemMethods.ByName("GetWatcherEvents")), + connect.WithHandlerOptions(opts...), + ) + filesystemRemoveWatcherHandler := connect.NewUnaryHandler( + FilesystemRemoveWatcherProcedure, + svc.RemoveWatcher, + connect.WithSchema(filesystemMethods.ByName("RemoveWatcher")), + connect.WithHandlerOptions(opts...), + ) + return "/filesystem.Filesystem/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case FilesystemStatProcedure: + filesystemStatHandler.ServeHTTP(w, r) + case FilesystemMakeDirProcedure: + filesystemMakeDirHandler.ServeHTTP(w, r) + case FilesystemMoveProcedure: + filesystemMoveHandler.ServeHTTP(w, r) + case FilesystemListDirProcedure: + filesystemListDirHandler.ServeHTTP(w, r) + case FilesystemRemoveProcedure: + filesystemRemoveHandler.ServeHTTP(w, r) + case FilesystemWatchDirProcedure: + filesystemWatchDirHandler.ServeHTTP(w, r) + case FilesystemCreateWatcherProcedure: + filesystemCreateWatcherHandler.ServeHTTP(w, r) + case FilesystemGetWatcherEventsProcedure: + filesystemGetWatcherEventsHandler.ServeHTTP(w, r) + case FilesystemRemoveWatcherProcedure: + filesystemRemoveWatcherHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedFilesystemHandler returns CodeUnimplemented from all methods. +type UnimplementedFilesystemHandler struct{} + +func (UnimplementedFilesystemHandler) Stat(context.Context, *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Stat is not implemented")) +} + +func (UnimplementedFilesystemHandler) MakeDir(context.Context, *connect.Request[filesystem.MakeDirRequest]) (*connect.Response[filesystem.MakeDirResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.MakeDir is not implemented")) +} + +func (UnimplementedFilesystemHandler) Move(context.Context, *connect.Request[filesystem.MoveRequest]) (*connect.Response[filesystem.MoveResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Move is not implemented")) +} + +func (UnimplementedFilesystemHandler) ListDir(context.Context, *connect.Request[filesystem.ListDirRequest]) (*connect.Response[filesystem.ListDirResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.ListDir is not implemented")) +} + +func (UnimplementedFilesystemHandler) Remove(context.Context, *connect.Request[filesystem.RemoveRequest]) (*connect.Response[filesystem.RemoveResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Remove is not implemented")) +} + +func (UnimplementedFilesystemHandler) WatchDir(context.Context, *connect.Request[filesystem.WatchDirRequest], *connect.ServerStream[filesystem.WatchDirResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.WatchDir is not implemented")) +} + +func (UnimplementedFilesystemHandler) CreateWatcher(context.Context, *connect.Request[filesystem.CreateWatcherRequest]) (*connect.Response[filesystem.CreateWatcherResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.CreateWatcher is not implemented")) +} + +func (UnimplementedFilesystemHandler) GetWatcherEvents(context.Context, *connect.Request[filesystem.GetWatcherEventsRequest]) (*connect.Response[filesystem.GetWatcherEventsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.GetWatcherEvents is not implemented")) +} + +func (UnimplementedFilesystemHandler) RemoveWatcher(context.Context, *connect.Request[filesystem.RemoveWatcherRequest]) (*connect.Response[filesystem.RemoveWatcherResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.RemoveWatcher is not implemented")) +} diff --git a/envd/internal/services/spec/process/process.pb.go b/envd/internal/services/spec/process/process.pb.go new file mode 100644 index 0000000..c7c0ee0 --- /dev/null +++ b/envd/internal/services/spec/process/process.pb.go @@ -0,0 +1,1970 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: process/process.proto + +package process + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Signal int32 + +const ( + Signal_SIGNAL_UNSPECIFIED Signal = 0 + Signal_SIGNAL_SIGTERM Signal = 15 + Signal_SIGNAL_SIGKILL Signal = 9 +) + +// Enum value maps for Signal. +var ( + Signal_name = map[int32]string{ + 0: "SIGNAL_UNSPECIFIED", + 15: "SIGNAL_SIGTERM", + 9: "SIGNAL_SIGKILL", + } + Signal_value = map[string]int32{ + "SIGNAL_UNSPECIFIED": 0, + "SIGNAL_SIGTERM": 15, + "SIGNAL_SIGKILL": 9, + } +) + +func (x Signal) Enum() *Signal { + p := new(Signal) + *p = x + return p +} + +func (x Signal) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Signal) Descriptor() protoreflect.EnumDescriptor { + return file_process_process_proto_enumTypes[0].Descriptor() +} + +func (Signal) Type() protoreflect.EnumType { + return &file_process_process_proto_enumTypes[0] +} + +func (x Signal) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Signal.Descriptor instead. +func (Signal) EnumDescriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{0} +} + +type PTY struct { + state protoimpl.MessageState `protogen:"open.v1"` + Size *PTY_Size `protobuf:"bytes,1,opt,name=size,proto3" json:"size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PTY) Reset() { + *x = PTY{} + mi := &file_process_process_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PTY) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PTY) ProtoMessage() {} + +func (x *PTY) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PTY.ProtoReflect.Descriptor instead. +func (*PTY) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{0} +} + +func (x *PTY) GetSize() *PTY_Size { + if x != nil { + return x.Size + } + return nil +} + +type ProcessConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"` + Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + Envs map[string]string `protobuf:"bytes,3,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Cwd *string `protobuf:"bytes,4,opt,name=cwd,proto3,oneof" json:"cwd,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessConfig) Reset() { + *x = ProcessConfig{} + mi := &file_process_process_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessConfig) ProtoMessage() {} + +func (x *ProcessConfig) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessConfig.ProtoReflect.Descriptor instead. +func (*ProcessConfig) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{1} +} + +func (x *ProcessConfig) GetCmd() string { + if x != nil { + return x.Cmd + } + return "" +} + +func (x *ProcessConfig) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ProcessConfig) GetEnvs() map[string]string { + if x != nil { + return x.Envs + } + return nil +} + +func (x *ProcessConfig) GetCwd() string { + if x != nil && x.Cwd != nil { + return *x.Cwd + } + return "" +} + +type ListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRequest) Reset() { + *x = ListRequest{} + mi := &file_process_process_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRequest) ProtoMessage() {} + +func (x *ListRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. +func (*ListRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{2} +} + +type ProcessInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Config *ProcessConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + Pid uint32 `protobuf:"varint,2,opt,name=pid,proto3" json:"pid,omitempty"` + Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInfo) Reset() { + *x = ProcessInfo{} + mi := &file_process_process_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInfo) ProtoMessage() {} + +func (x *ProcessInfo) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInfo.ProtoReflect.Descriptor instead. +func (*ProcessInfo) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{3} +} + +func (x *ProcessInfo) GetConfig() *ProcessConfig { + if x != nil { + return x.Config + } + return nil +} + +func (x *ProcessInfo) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +func (x *ProcessInfo) GetTag() string { + if x != nil && x.Tag != nil { + return *x.Tag + } + return "" +} + +type ListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Processes []*ProcessInfo `protobuf:"bytes,1,rep,name=processes,proto3" json:"processes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListResponse) Reset() { + *x = ListResponse{} + mi := &file_process_process_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListResponse) ProtoMessage() {} + +func (x *ListResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListResponse.ProtoReflect.Descriptor instead. +func (*ListResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{4} +} + +func (x *ListResponse) GetProcesses() []*ProcessInfo { + if x != nil { + return x.Processes + } + return nil +} + +type StartRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessConfig `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` + Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` + // This is optional for backwards compatibility. + // We default to true. New SDK versions will set this to false by default. + Stdin *bool `protobuf:"varint,4,opt,name=stdin,proto3,oneof" json:"stdin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartRequest) Reset() { + *x = StartRequest{} + mi := &file_process_process_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartRequest) ProtoMessage() {} + +func (x *StartRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartRequest.ProtoReflect.Descriptor instead. +func (*StartRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{5} +} + +func (x *StartRequest) GetProcess() *ProcessConfig { + if x != nil { + return x.Process + } + return nil +} + +func (x *StartRequest) GetPty() *PTY { + if x != nil { + return x.Pty + } + return nil +} + +func (x *StartRequest) GetTag() string { + if x != nil && x.Tag != nil { + return *x.Tag + } + return "" +} + +func (x *StartRequest) GetStdin() bool { + if x != nil && x.Stdin != nil { + return *x.Stdin + } + return false +} + +type UpdateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateRequest) Reset() { + *x = UpdateRequest{} + mi := &file_process_process_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateRequest) ProtoMessage() {} + +func (x *UpdateRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateRequest.ProtoReflect.Descriptor instead. +func (*UpdateRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{6} +} + +func (x *UpdateRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *UpdateRequest) GetPty() *PTY { + if x != nil { + return x.Pty + } + return nil +} + +type UpdateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateResponse) Reset() { + *x = UpdateResponse{} + mi := &file_process_process_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateResponse) ProtoMessage() {} + +func (x *UpdateResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateResponse.ProtoReflect.Descriptor instead. +func (*UpdateResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{7} +} + +type ProcessEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *ProcessEvent_Start + // *ProcessEvent_Data + // *ProcessEvent_End + // *ProcessEvent_Keepalive + Event isProcessEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent) Reset() { + *x = ProcessEvent{} + mi := &file_process_process_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent) ProtoMessage() {} + +func (x *ProcessEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{8} +} + +func (x *ProcessEvent) GetEvent() isProcessEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *ProcessEvent) GetStart() *ProcessEvent_StartEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ProcessEvent) GetData() *ProcessEvent_DataEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Data); ok { + return x.Data + } + } + return nil +} + +func (x *ProcessEvent) GetEnd() *ProcessEvent_EndEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_End); ok { + return x.End + } + } + return nil +} + +func (x *ProcessEvent) GetKeepalive() *ProcessEvent_KeepAlive { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isProcessEvent_Event interface { + isProcessEvent_Event() +} + +type ProcessEvent_Start struct { + Start *ProcessEvent_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ProcessEvent_Data struct { + Data *ProcessEvent_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type ProcessEvent_End struct { + End *ProcessEvent_EndEvent `protobuf:"bytes,3,opt,name=end,proto3,oneof"` +} + +type ProcessEvent_Keepalive struct { + Keepalive *ProcessEvent_KeepAlive `protobuf:"bytes,4,opt,name=keepalive,proto3,oneof"` +} + +func (*ProcessEvent_Start) isProcessEvent_Event() {} + +func (*ProcessEvent_Data) isProcessEvent_Event() {} + +func (*ProcessEvent_End) isProcessEvent_Event() {} + +func (*ProcessEvent_Keepalive) isProcessEvent_Event() {} + +type StartResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartResponse) Reset() { + *x = StartResponse{} + mi := &file_process_process_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartResponse) ProtoMessage() {} + +func (x *StartResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartResponse.ProtoReflect.Descriptor instead. +func (*StartResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{9} +} + +func (x *StartResponse) GetEvent() *ProcessEvent { + if x != nil { + return x.Event + } + return nil +} + +type ConnectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectResponse) Reset() { + *x = ConnectResponse{} + mi := &file_process_process_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectResponse) ProtoMessage() {} + +func (x *ConnectResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectResponse.ProtoReflect.Descriptor instead. +func (*ConnectResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{10} +} + +func (x *ConnectResponse) GetEvent() *ProcessEvent { + if x != nil { + return x.Event + } + return nil +} + +type SendInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendInputRequest) Reset() { + *x = SendInputRequest{} + mi := &file_process_process_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendInputRequest) ProtoMessage() {} + +func (x *SendInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendInputRequest.ProtoReflect.Descriptor instead. +func (*SendInputRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{11} +} + +func (x *SendInputRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *SendInputRequest) GetInput() *ProcessInput { + if x != nil { + return x.Input + } + return nil +} + +type SendInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendInputResponse) Reset() { + *x = SendInputResponse{} + mi := &file_process_process_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendInputResponse) ProtoMessage() {} + +func (x *SendInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendInputResponse.ProtoReflect.Descriptor instead. +func (*SendInputResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{12} +} + +type ProcessInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Input: + // + // *ProcessInput_Stdin + // *ProcessInput_Pty + Input isProcessInput_Input `protobuf_oneof:"input"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInput) Reset() { + *x = ProcessInput{} + mi := &file_process_process_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInput) ProtoMessage() {} + +func (x *ProcessInput) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInput.ProtoReflect.Descriptor instead. +func (*ProcessInput) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{13} +} + +func (x *ProcessInput) GetInput() isProcessInput_Input { + if x != nil { + return x.Input + } + return nil +} + +func (x *ProcessInput) GetStdin() []byte { + if x != nil { + if x, ok := x.Input.(*ProcessInput_Stdin); ok { + return x.Stdin + } + } + return nil +} + +func (x *ProcessInput) GetPty() []byte { + if x != nil { + if x, ok := x.Input.(*ProcessInput_Pty); ok { + return x.Pty + } + } + return nil +} + +type isProcessInput_Input interface { + isProcessInput_Input() +} + +type ProcessInput_Stdin struct { + Stdin []byte `protobuf:"bytes,1,opt,name=stdin,proto3,oneof"` +} + +type ProcessInput_Pty struct { + Pty []byte `protobuf:"bytes,2,opt,name=pty,proto3,oneof"` +} + +func (*ProcessInput_Stdin) isProcessInput_Input() {} + +func (*ProcessInput_Pty) isProcessInput_Input() {} + +type StreamInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *StreamInputRequest_Start + // *StreamInputRequest_Data + // *StreamInputRequest_Keepalive + Event isStreamInputRequest_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest) Reset() { + *x = StreamInputRequest{} + mi := &file_process_process_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest) ProtoMessage() {} + +func (x *StreamInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest.ProtoReflect.Descriptor instead. +func (*StreamInputRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{14} +} + +func (x *StreamInputRequest) GetEvent() isStreamInputRequest_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *StreamInputRequest) GetStart() *StreamInputRequest_StartEvent { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Start); ok { + return x.Start + } + } + return nil +} + +func (x *StreamInputRequest) GetData() *StreamInputRequest_DataEvent { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Data); ok { + return x.Data + } + } + return nil +} + +func (x *StreamInputRequest) GetKeepalive() *StreamInputRequest_KeepAlive { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isStreamInputRequest_Event interface { + isStreamInputRequest_Event() +} + +type StreamInputRequest_Start struct { + Start *StreamInputRequest_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type StreamInputRequest_Data struct { + Data *StreamInputRequest_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type StreamInputRequest_Keepalive struct { + Keepalive *StreamInputRequest_KeepAlive `protobuf:"bytes,3,opt,name=keepalive,proto3,oneof"` +} + +func (*StreamInputRequest_Start) isStreamInputRequest_Event() {} + +func (*StreamInputRequest_Data) isStreamInputRequest_Event() {} + +func (*StreamInputRequest_Keepalive) isStreamInputRequest_Event() {} + +type StreamInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputResponse) Reset() { + *x = StreamInputResponse{} + mi := &file_process_process_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputResponse) ProtoMessage() {} + +func (x *StreamInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputResponse.ProtoReflect.Descriptor instead. +func (*StreamInputResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{15} +} + +type SendSignalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Signal Signal `protobuf:"varint,2,opt,name=signal,proto3,enum=process.Signal" json:"signal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendSignalRequest) Reset() { + *x = SendSignalRequest{} + mi := &file_process_process_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendSignalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendSignalRequest) ProtoMessage() {} + +func (x *SendSignalRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendSignalRequest.ProtoReflect.Descriptor instead. +func (*SendSignalRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{16} +} + +func (x *SendSignalRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *SendSignalRequest) GetSignal() Signal { + if x != nil { + return x.Signal + } + return Signal_SIGNAL_UNSPECIFIED +} + +type SendSignalResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendSignalResponse) Reset() { + *x = SendSignalResponse{} + mi := &file_process_process_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendSignalResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendSignalResponse) ProtoMessage() {} + +func (x *SendSignalResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendSignalResponse.ProtoReflect.Descriptor instead. +func (*SendSignalResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{17} +} + +type CloseStdinRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseStdinRequest) Reset() { + *x = CloseStdinRequest{} + mi := &file_process_process_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseStdinRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseStdinRequest) ProtoMessage() {} + +func (x *CloseStdinRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseStdinRequest.ProtoReflect.Descriptor instead. +func (*CloseStdinRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{18} +} + +func (x *CloseStdinRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type CloseStdinResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseStdinResponse) Reset() { + *x = CloseStdinResponse{} + mi := &file_process_process_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseStdinResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseStdinResponse) ProtoMessage() {} + +func (x *CloseStdinResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseStdinResponse.ProtoReflect.Descriptor instead. +func (*CloseStdinResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{19} +} + +type ConnectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectRequest) Reset() { + *x = ConnectRequest{} + mi := &file_process_process_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectRequest) ProtoMessage() {} + +func (x *ConnectRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectRequest.ProtoReflect.Descriptor instead. +func (*ConnectRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{20} +} + +func (x *ConnectRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type ProcessSelector struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Selector: + // + // *ProcessSelector_Pid + // *ProcessSelector_Tag + Selector isProcessSelector_Selector `protobuf_oneof:"selector"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessSelector) Reset() { + *x = ProcessSelector{} + mi := &file_process_process_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessSelector) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessSelector) ProtoMessage() {} + +func (x *ProcessSelector) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessSelector.ProtoReflect.Descriptor instead. +func (*ProcessSelector) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{21} +} + +func (x *ProcessSelector) GetSelector() isProcessSelector_Selector { + if x != nil { + return x.Selector + } + return nil +} + +func (x *ProcessSelector) GetPid() uint32 { + if x != nil { + if x, ok := x.Selector.(*ProcessSelector_Pid); ok { + return x.Pid + } + } + return 0 +} + +func (x *ProcessSelector) GetTag() string { + if x != nil { + if x, ok := x.Selector.(*ProcessSelector_Tag); ok { + return x.Tag + } + } + return "" +} + +type isProcessSelector_Selector interface { + isProcessSelector_Selector() +} + +type ProcessSelector_Pid struct { + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3,oneof"` +} + +type ProcessSelector_Tag struct { + Tag string `protobuf:"bytes,2,opt,name=tag,proto3,oneof"` +} + +func (*ProcessSelector_Pid) isProcessSelector_Selector() {} + +func (*ProcessSelector_Tag) isProcessSelector_Selector() {} + +type PTY_Size struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cols uint32 `protobuf:"varint,1,opt,name=cols,proto3" json:"cols,omitempty"` + Rows uint32 `protobuf:"varint,2,opt,name=rows,proto3" json:"rows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PTY_Size) Reset() { + *x = PTY_Size{} + mi := &file_process_process_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PTY_Size) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PTY_Size) ProtoMessage() {} + +func (x *PTY_Size) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PTY_Size.ProtoReflect.Descriptor instead. +func (*PTY_Size) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *PTY_Size) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *PTY_Size) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +type ProcessEvent_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3" json:"pid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_StartEvent) Reset() { + *x = ProcessEvent_StartEvent{} + mi := &file_process_process_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_StartEvent) ProtoMessage() {} + +func (x *ProcessEvent_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_StartEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_StartEvent) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{8, 0} +} + +func (x *ProcessEvent_StartEvent) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +type ProcessEvent_DataEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Output: + // + // *ProcessEvent_DataEvent_Stdout + // *ProcessEvent_DataEvent_Stderr + // *ProcessEvent_DataEvent_Pty + Output isProcessEvent_DataEvent_Output `protobuf_oneof:"output"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_DataEvent) Reset() { + *x = ProcessEvent_DataEvent{} + mi := &file_process_process_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_DataEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_DataEvent) ProtoMessage() {} + +func (x *ProcessEvent_DataEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_DataEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_DataEvent) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{8, 1} +} + +func (x *ProcessEvent_DataEvent) GetOutput() isProcessEvent_DataEvent_Output { + if x != nil { + return x.Output + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetStdout() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetStderr() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Stderr); ok { + return x.Stderr + } + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetPty() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Pty); ok { + return x.Pty + } + } + return nil +} + +type isProcessEvent_DataEvent_Output interface { + isProcessEvent_DataEvent_Output() +} + +type ProcessEvent_DataEvent_Stdout struct { + Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` +} + +type ProcessEvent_DataEvent_Stderr struct { + Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` +} + +type ProcessEvent_DataEvent_Pty struct { + Pty []byte `protobuf:"bytes,3,opt,name=pty,proto3,oneof"` +} + +func (*ProcessEvent_DataEvent_Stdout) isProcessEvent_DataEvent_Output() {} + +func (*ProcessEvent_DataEvent_Stderr) isProcessEvent_DataEvent_Output() {} + +func (*ProcessEvent_DataEvent_Pty) isProcessEvent_DataEvent_Output() {} + +type ProcessEvent_EndEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"zigzag32,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Exited bool `protobuf:"varint,2,opt,name=exited,proto3" json:"exited,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + Error *string `protobuf:"bytes,4,opt,name=error,proto3,oneof" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_EndEvent) Reset() { + *x = ProcessEvent_EndEvent{} + mi := &file_process_process_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_EndEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_EndEvent) ProtoMessage() {} + +func (x *ProcessEvent_EndEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_EndEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_EndEvent) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{8, 2} +} + +func (x *ProcessEvent_EndEvent) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *ProcessEvent_EndEvent) GetExited() bool { + if x != nil { + return x.Exited + } + return false +} + +func (x *ProcessEvent_EndEvent) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ProcessEvent_EndEvent) GetError() string { + if x != nil && x.Error != nil { + return *x.Error + } + return "" +} + +type ProcessEvent_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_KeepAlive) Reset() { + *x = ProcessEvent_KeepAlive{} + mi := &file_process_process_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_KeepAlive) ProtoMessage() {} + +func (x *ProcessEvent_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_KeepAlive.ProtoReflect.Descriptor instead. +func (*ProcessEvent_KeepAlive) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{8, 3} +} + +type StreamInputRequest_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_StartEvent) Reset() { + *x = StreamInputRequest_StartEvent{} + mi := &file_process_process_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_StartEvent) ProtoMessage() {} + +func (x *StreamInputRequest_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_StartEvent.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_StartEvent) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{14, 0} +} + +func (x *StreamInputRequest_StartEvent) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type StreamInputRequest_DataEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_DataEvent) Reset() { + *x = StreamInputRequest_DataEvent{} + mi := &file_process_process_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_DataEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_DataEvent) ProtoMessage() {} + +func (x *StreamInputRequest_DataEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_DataEvent.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_DataEvent) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{14, 1} +} + +func (x *StreamInputRequest_DataEvent) GetInput() *ProcessInput { + if x != nil { + return x.Input + } + return nil +} + +type StreamInputRequest_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_KeepAlive) Reset() { + *x = StreamInputRequest_KeepAlive{} + mi := &file_process_process_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_KeepAlive) ProtoMessage() {} + +func (x *StreamInputRequest_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_KeepAlive.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_KeepAlive) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{14, 2} +} + +var File_process_process_proto protoreflect.FileDescriptor + +const file_process_process_proto_rawDesc = "" + + "\n" + + "\x15process/process.proto\x12\aprocess\"\\\n" + + "\x03PTY\x12%\n" + + "\x04size\x18\x01 \x01(\v2\x11.process.PTY.SizeR\x04size\x1a.\n" + + "\x04Size\x12\x12\n" + + "\x04cols\x18\x01 \x01(\rR\x04cols\x12\x12\n" + + "\x04rows\x18\x02 \x01(\rR\x04rows\"\xc3\x01\n" + + "\rProcessConfig\x12\x10\n" + + "\x03cmd\x18\x01 \x01(\tR\x03cmd\x12\x12\n" + + "\x04args\x18\x02 \x03(\tR\x04args\x124\n" + + "\x04envs\x18\x03 \x03(\v2 .process.ProcessConfig.EnvsEntryR\x04envs\x12\x15\n" + + "\x03cwd\x18\x04 \x01(\tH\x00R\x03cwd\x88\x01\x01\x1a7\n" + + "\tEnvsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" + + "\x04_cwd\"\r\n" + + "\vListRequest\"n\n" + + "\vProcessInfo\x12.\n" + + "\x06config\x18\x01 \x01(\v2\x16.process.ProcessConfigR\x06config\x12\x10\n" + + "\x03pid\x18\x02 \x01(\rR\x03pid\x12\x15\n" + + "\x03tag\x18\x03 \x01(\tH\x00R\x03tag\x88\x01\x01B\x06\n" + + "\x04_tag\"B\n" + + "\fListResponse\x122\n" + + "\tprocesses\x18\x01 \x03(\v2\x14.process.ProcessInfoR\tprocesses\"\xb1\x01\n" + + "\fStartRequest\x120\n" + + "\aprocess\x18\x01 \x01(\v2\x16.process.ProcessConfigR\aprocess\x12#\n" + + "\x03pty\x18\x02 \x01(\v2\f.process.PTYH\x00R\x03pty\x88\x01\x01\x12\x15\n" + + "\x03tag\x18\x03 \x01(\tH\x01R\x03tag\x88\x01\x01\x12\x19\n" + + "\x05stdin\x18\x04 \x01(\bH\x02R\x05stdin\x88\x01\x01B\x06\n" + + "\x04_ptyB\x06\n" + + "\x04_tagB\b\n" + + "\x06_stdin\"p\n" + + "\rUpdateRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12#\n" + + "\x03pty\x18\x02 \x01(\v2\f.process.PTYH\x00R\x03pty\x88\x01\x01B\x06\n" + + "\x04_pty\"\x10\n" + + "\x0eUpdateResponse\"\x87\x04\n" + + "\fProcessEvent\x128\n" + + "\x05start\x18\x01 \x01(\v2 .process.ProcessEvent.StartEventH\x00R\x05start\x125\n" + + "\x04data\x18\x02 \x01(\v2\x1f.process.ProcessEvent.DataEventH\x00R\x04data\x122\n" + + "\x03end\x18\x03 \x01(\v2\x1e.process.ProcessEvent.EndEventH\x00R\x03end\x12?\n" + + "\tkeepalive\x18\x04 \x01(\v2\x1f.process.ProcessEvent.KeepAliveH\x00R\tkeepalive\x1a\x1e\n" + + "\n" + + "StartEvent\x12\x10\n" + + "\x03pid\x18\x01 \x01(\rR\x03pid\x1a]\n" + + "\tDataEvent\x12\x18\n" + + "\x06stdout\x18\x01 \x01(\fH\x00R\x06stdout\x12\x18\n" + + "\x06stderr\x18\x02 \x01(\fH\x00R\x06stderr\x12\x12\n" + + "\x03pty\x18\x03 \x01(\fH\x00R\x03ptyB\b\n" + + "\x06output\x1a|\n" + + "\bEndEvent\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x11R\bexitCode\x12\x16\n" + + "\x06exited\x18\x02 \x01(\bR\x06exited\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12\x19\n" + + "\x05error\x18\x04 \x01(\tH\x00R\x05error\x88\x01\x01B\b\n" + + "\x06_error\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"<\n" + + "\rStartResponse\x12+\n" + + "\x05event\x18\x01 \x01(\v2\x15.process.ProcessEventR\x05event\">\n" + + "\x0fConnectResponse\x12+\n" + + "\x05event\x18\x01 \x01(\v2\x15.process.ProcessEventR\x05event\"s\n" + + "\x10SendInputRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12+\n" + + "\x05input\x18\x02 \x01(\v2\x15.process.ProcessInputR\x05input\"\x13\n" + + "\x11SendInputResponse\"C\n" + + "\fProcessInput\x12\x16\n" + + "\x05stdin\x18\x01 \x01(\fH\x00R\x05stdin\x12\x12\n" + + "\x03pty\x18\x02 \x01(\fH\x00R\x03ptyB\a\n" + + "\x05input\"\xea\x02\n" + + "\x12StreamInputRequest\x12>\n" + + "\x05start\x18\x01 \x01(\v2&.process.StreamInputRequest.StartEventH\x00R\x05start\x12;\n" + + "\x04data\x18\x02 \x01(\v2%.process.StreamInputRequest.DataEventH\x00R\x04data\x12E\n" + + "\tkeepalive\x18\x03 \x01(\v2%.process.StreamInputRequest.KeepAliveH\x00R\tkeepalive\x1a@\n" + + "\n" + + "StartEvent\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x1a8\n" + + "\tDataEvent\x12+\n" + + "\x05input\x18\x02 \x01(\v2\x15.process.ProcessInputR\x05input\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"\x15\n" + + "\x13StreamInputResponse\"p\n" + + "\x11SendSignalRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12'\n" + + "\x06signal\x18\x02 \x01(\x0e2\x0f.process.SignalR\x06signal\"\x14\n" + + "\x12SendSignalResponse\"G\n" + + "\x11CloseStdinRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\"\x14\n" + + "\x12CloseStdinResponse\"D\n" + + "\x0eConnectRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\"E\n" + + "\x0fProcessSelector\x12\x12\n" + + "\x03pid\x18\x01 \x01(\rH\x00R\x03pid\x12\x12\n" + + "\x03tag\x18\x02 \x01(\tH\x00R\x03tagB\n" + + "\n" + + "\bselector*H\n" + + "\x06Signal\x12\x16\n" + + "\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eSIGNAL_SIGTERM\x10\x0f\x12\x12\n" + + "\x0eSIGNAL_SIGKILL\x10\t2\x91\x04\n" + + "\aProcess\x123\n" + + "\x04List\x12\x14.process.ListRequest\x1a\x15.process.ListResponse\x12>\n" + + "\aConnect\x12\x17.process.ConnectRequest\x1a\x18.process.ConnectResponse0\x01\x128\n" + + "\x05Start\x12\x15.process.StartRequest\x1a\x16.process.StartResponse0\x01\x129\n" + + "\x06Update\x12\x16.process.UpdateRequest\x1a\x17.process.UpdateResponse\x12J\n" + + "\vStreamInput\x12\x1b.process.StreamInputRequest\x1a\x1c.process.StreamInputResponse(\x01\x12B\n" + + "\tSendInput\x12\x19.process.SendInputRequest\x1a\x1a.process.SendInputResponse\x12E\n" + + "\n" + + "SendSignal\x12\x1a.process.SendSignalRequest\x1a\x1b.process.SendSignalResponse\x12E\n" + + "\n" + + "CloseStdin\x12\x1a.process.CloseStdinRequest\x1a\x1b.process.CloseStdinResponseB\x98\x01\n" + + "\vcom.processB\fProcessProtoP\x01Z?git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process\xa2\x02\x03PXX\xaa\x02\aProcess\xca\x02\aProcess\xe2\x02\x13Process\\GPBMetadata\xea\x02\aProcessb\x06proto3" + +var ( + file_process_process_proto_rawDescOnce sync.Once + file_process_process_proto_rawDescData []byte +) + +func file_process_process_proto_rawDescGZIP() []byte { + file_process_process_proto_rawDescOnce.Do(func() { + file_process_process_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_process_process_proto_rawDesc), len(file_process_process_proto_rawDesc))) + }) + return file_process_process_proto_rawDescData +} + +var file_process_process_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_process_process_proto_msgTypes = make([]protoimpl.MessageInfo, 31) +var file_process_process_proto_goTypes = []any{ + (Signal)(0), // 0: process.Signal + (*PTY)(nil), // 1: process.PTY + (*ProcessConfig)(nil), // 2: process.ProcessConfig + (*ListRequest)(nil), // 3: process.ListRequest + (*ProcessInfo)(nil), // 4: process.ProcessInfo + (*ListResponse)(nil), // 5: process.ListResponse + (*StartRequest)(nil), // 6: process.StartRequest + (*UpdateRequest)(nil), // 7: process.UpdateRequest + (*UpdateResponse)(nil), // 8: process.UpdateResponse + (*ProcessEvent)(nil), // 9: process.ProcessEvent + (*StartResponse)(nil), // 10: process.StartResponse + (*ConnectResponse)(nil), // 11: process.ConnectResponse + (*SendInputRequest)(nil), // 12: process.SendInputRequest + (*SendInputResponse)(nil), // 13: process.SendInputResponse + (*ProcessInput)(nil), // 14: process.ProcessInput + (*StreamInputRequest)(nil), // 15: process.StreamInputRequest + (*StreamInputResponse)(nil), // 16: process.StreamInputResponse + (*SendSignalRequest)(nil), // 17: process.SendSignalRequest + (*SendSignalResponse)(nil), // 18: process.SendSignalResponse + (*CloseStdinRequest)(nil), // 19: process.CloseStdinRequest + (*CloseStdinResponse)(nil), // 20: process.CloseStdinResponse + (*ConnectRequest)(nil), // 21: process.ConnectRequest + (*ProcessSelector)(nil), // 22: process.ProcessSelector + (*PTY_Size)(nil), // 23: process.PTY.Size + nil, // 24: process.ProcessConfig.EnvsEntry + (*ProcessEvent_StartEvent)(nil), // 25: process.ProcessEvent.StartEvent + (*ProcessEvent_DataEvent)(nil), // 26: process.ProcessEvent.DataEvent + (*ProcessEvent_EndEvent)(nil), // 27: process.ProcessEvent.EndEvent + (*ProcessEvent_KeepAlive)(nil), // 28: process.ProcessEvent.KeepAlive + (*StreamInputRequest_StartEvent)(nil), // 29: process.StreamInputRequest.StartEvent + (*StreamInputRequest_DataEvent)(nil), // 30: process.StreamInputRequest.DataEvent + (*StreamInputRequest_KeepAlive)(nil), // 31: process.StreamInputRequest.KeepAlive +} +var file_process_process_proto_depIdxs = []int32{ + 23, // 0: process.PTY.size:type_name -> process.PTY.Size + 24, // 1: process.ProcessConfig.envs:type_name -> process.ProcessConfig.EnvsEntry + 2, // 2: process.ProcessInfo.config:type_name -> process.ProcessConfig + 4, // 3: process.ListResponse.processes:type_name -> process.ProcessInfo + 2, // 4: process.StartRequest.process:type_name -> process.ProcessConfig + 1, // 5: process.StartRequest.pty:type_name -> process.PTY + 22, // 6: process.UpdateRequest.process:type_name -> process.ProcessSelector + 1, // 7: process.UpdateRequest.pty:type_name -> process.PTY + 25, // 8: process.ProcessEvent.start:type_name -> process.ProcessEvent.StartEvent + 26, // 9: process.ProcessEvent.data:type_name -> process.ProcessEvent.DataEvent + 27, // 10: process.ProcessEvent.end:type_name -> process.ProcessEvent.EndEvent + 28, // 11: process.ProcessEvent.keepalive:type_name -> process.ProcessEvent.KeepAlive + 9, // 12: process.StartResponse.event:type_name -> process.ProcessEvent + 9, // 13: process.ConnectResponse.event:type_name -> process.ProcessEvent + 22, // 14: process.SendInputRequest.process:type_name -> process.ProcessSelector + 14, // 15: process.SendInputRequest.input:type_name -> process.ProcessInput + 29, // 16: process.StreamInputRequest.start:type_name -> process.StreamInputRequest.StartEvent + 30, // 17: process.StreamInputRequest.data:type_name -> process.StreamInputRequest.DataEvent + 31, // 18: process.StreamInputRequest.keepalive:type_name -> process.StreamInputRequest.KeepAlive + 22, // 19: process.SendSignalRequest.process:type_name -> process.ProcessSelector + 0, // 20: process.SendSignalRequest.signal:type_name -> process.Signal + 22, // 21: process.CloseStdinRequest.process:type_name -> process.ProcessSelector + 22, // 22: process.ConnectRequest.process:type_name -> process.ProcessSelector + 22, // 23: process.StreamInputRequest.StartEvent.process:type_name -> process.ProcessSelector + 14, // 24: process.StreamInputRequest.DataEvent.input:type_name -> process.ProcessInput + 3, // 25: process.Process.List:input_type -> process.ListRequest + 21, // 26: process.Process.Connect:input_type -> process.ConnectRequest + 6, // 27: process.Process.Start:input_type -> process.StartRequest + 7, // 28: process.Process.Update:input_type -> process.UpdateRequest + 15, // 29: process.Process.StreamInput:input_type -> process.StreamInputRequest + 12, // 30: process.Process.SendInput:input_type -> process.SendInputRequest + 17, // 31: process.Process.SendSignal:input_type -> process.SendSignalRequest + 19, // 32: process.Process.CloseStdin:input_type -> process.CloseStdinRequest + 5, // 33: process.Process.List:output_type -> process.ListResponse + 11, // 34: process.Process.Connect:output_type -> process.ConnectResponse + 10, // 35: process.Process.Start:output_type -> process.StartResponse + 8, // 36: process.Process.Update:output_type -> process.UpdateResponse + 16, // 37: process.Process.StreamInput:output_type -> process.StreamInputResponse + 13, // 38: process.Process.SendInput:output_type -> process.SendInputResponse + 18, // 39: process.Process.SendSignal:output_type -> process.SendSignalResponse + 20, // 40: process.Process.CloseStdin:output_type -> process.CloseStdinResponse + 33, // [33:41] is the sub-list for method output_type + 25, // [25:33] is the sub-list for method input_type + 25, // [25:25] is the sub-list for extension type_name + 25, // [25:25] is the sub-list for extension extendee + 0, // [0:25] is the sub-list for field type_name +} + +func init() { file_process_process_proto_init() } +func file_process_process_proto_init() { + if File_process_process_proto != nil { + return + } + file_process_process_proto_msgTypes[1].OneofWrappers = []any{} + file_process_process_proto_msgTypes[3].OneofWrappers = []any{} + file_process_process_proto_msgTypes[5].OneofWrappers = []any{} + file_process_process_proto_msgTypes[6].OneofWrappers = []any{} + file_process_process_proto_msgTypes[8].OneofWrappers = []any{ + (*ProcessEvent_Start)(nil), + (*ProcessEvent_Data)(nil), + (*ProcessEvent_End)(nil), + (*ProcessEvent_Keepalive)(nil), + } + file_process_process_proto_msgTypes[13].OneofWrappers = []any{ + (*ProcessInput_Stdin)(nil), + (*ProcessInput_Pty)(nil), + } + file_process_process_proto_msgTypes[14].OneofWrappers = []any{ + (*StreamInputRequest_Start)(nil), + (*StreamInputRequest_Data)(nil), + (*StreamInputRequest_Keepalive)(nil), + } + file_process_process_proto_msgTypes[21].OneofWrappers = []any{ + (*ProcessSelector_Pid)(nil), + (*ProcessSelector_Tag)(nil), + } + file_process_process_proto_msgTypes[25].OneofWrappers = []any{ + (*ProcessEvent_DataEvent_Stdout)(nil), + (*ProcessEvent_DataEvent_Stderr)(nil), + (*ProcessEvent_DataEvent_Pty)(nil), + } + file_process_process_proto_msgTypes[26].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_process_process_proto_rawDesc), len(file_process_process_proto_rawDesc)), + NumEnums: 1, + NumMessages: 31, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_process_process_proto_goTypes, + DependencyIndexes: file_process_process_proto_depIdxs, + EnumInfos: file_process_process_proto_enumTypes, + MessageInfos: file_process_process_proto_msgTypes, + }.Build() + File_process_process_proto = out.File + file_process_process_proto_goTypes = nil + file_process_process_proto_depIdxs = nil +} diff --git a/envd/internal/services/spec/process/processconnect/process.connect.go b/envd/internal/services/spec/process/processconnect/process.connect.go new file mode 100644 index 0000000..7a4f3f8 --- /dev/null +++ b/envd/internal/services/spec/process/processconnect/process.connect.go @@ -0,0 +1,310 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: process/process.proto + +package processconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + process "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // ProcessName is the fully-qualified name of the Process service. + ProcessName = "process.Process" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // ProcessListProcedure is the fully-qualified name of the Process's List RPC. + ProcessListProcedure = "/process.Process/List" + // ProcessConnectProcedure is the fully-qualified name of the Process's Connect RPC. + ProcessConnectProcedure = "/process.Process/Connect" + // ProcessStartProcedure is the fully-qualified name of the Process's Start RPC. + ProcessStartProcedure = "/process.Process/Start" + // ProcessUpdateProcedure is the fully-qualified name of the Process's Update RPC. + ProcessUpdateProcedure = "/process.Process/Update" + // ProcessStreamInputProcedure is the fully-qualified name of the Process's StreamInput RPC. + ProcessStreamInputProcedure = "/process.Process/StreamInput" + // ProcessSendInputProcedure is the fully-qualified name of the Process's SendInput RPC. + ProcessSendInputProcedure = "/process.Process/SendInput" + // ProcessSendSignalProcedure is the fully-qualified name of the Process's SendSignal RPC. + ProcessSendSignalProcedure = "/process.Process/SendSignal" + // ProcessCloseStdinProcedure is the fully-qualified name of the Process's CloseStdin RPC. + ProcessCloseStdinProcedure = "/process.Process/CloseStdin" +) + +// ProcessClient is a client for the process.Process service. +type ProcessClient interface { + List(context.Context, *connect.Request[process.ListRequest]) (*connect.Response[process.ListResponse], error) + Connect(context.Context, *connect.Request[process.ConnectRequest]) (*connect.ServerStreamForClient[process.ConnectResponse], error) + Start(context.Context, *connect.Request[process.StartRequest]) (*connect.ServerStreamForClient[process.StartResponse], error) + Update(context.Context, *connect.Request[process.UpdateRequest]) (*connect.Response[process.UpdateResponse], error) + // Client input stream ensures ordering of messages + StreamInput(context.Context) *connect.ClientStreamForClient[process.StreamInputRequest, process.StreamInputResponse] + SendInput(context.Context, *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) + SendSignal(context.Context, *connect.Request[process.SendSignalRequest]) (*connect.Response[process.SendSignalResponse], error) + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + CloseStdin(context.Context, *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) +} + +// NewProcessClient constructs a client for the process.Process service. By default, it uses the +// Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewProcessClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ProcessClient { + baseURL = strings.TrimRight(baseURL, "/") + processMethods := process.File_process_process_proto.Services().ByName("Process").Methods() + return &processClient{ + list: connect.NewClient[process.ListRequest, process.ListResponse]( + httpClient, + baseURL+ProcessListProcedure, + connect.WithSchema(processMethods.ByName("List")), + connect.WithClientOptions(opts...), + ), + connect: connect.NewClient[process.ConnectRequest, process.ConnectResponse]( + httpClient, + baseURL+ProcessConnectProcedure, + connect.WithSchema(processMethods.ByName("Connect")), + connect.WithClientOptions(opts...), + ), + start: connect.NewClient[process.StartRequest, process.StartResponse]( + httpClient, + baseURL+ProcessStartProcedure, + connect.WithSchema(processMethods.ByName("Start")), + connect.WithClientOptions(opts...), + ), + update: connect.NewClient[process.UpdateRequest, process.UpdateResponse]( + httpClient, + baseURL+ProcessUpdateProcedure, + connect.WithSchema(processMethods.ByName("Update")), + connect.WithClientOptions(opts...), + ), + streamInput: connect.NewClient[process.StreamInputRequest, process.StreamInputResponse]( + httpClient, + baseURL+ProcessStreamInputProcedure, + connect.WithSchema(processMethods.ByName("StreamInput")), + connect.WithClientOptions(opts...), + ), + sendInput: connect.NewClient[process.SendInputRequest, process.SendInputResponse]( + httpClient, + baseURL+ProcessSendInputProcedure, + connect.WithSchema(processMethods.ByName("SendInput")), + connect.WithClientOptions(opts...), + ), + sendSignal: connect.NewClient[process.SendSignalRequest, process.SendSignalResponse]( + httpClient, + baseURL+ProcessSendSignalProcedure, + connect.WithSchema(processMethods.ByName("SendSignal")), + connect.WithClientOptions(opts...), + ), + closeStdin: connect.NewClient[process.CloseStdinRequest, process.CloseStdinResponse]( + httpClient, + baseURL+ProcessCloseStdinProcedure, + connect.WithSchema(processMethods.ByName("CloseStdin")), + connect.WithClientOptions(opts...), + ), + } +} + +// processClient implements ProcessClient. +type processClient struct { + list *connect.Client[process.ListRequest, process.ListResponse] + connect *connect.Client[process.ConnectRequest, process.ConnectResponse] + start *connect.Client[process.StartRequest, process.StartResponse] + update *connect.Client[process.UpdateRequest, process.UpdateResponse] + streamInput *connect.Client[process.StreamInputRequest, process.StreamInputResponse] + sendInput *connect.Client[process.SendInputRequest, process.SendInputResponse] + sendSignal *connect.Client[process.SendSignalRequest, process.SendSignalResponse] + closeStdin *connect.Client[process.CloseStdinRequest, process.CloseStdinResponse] +} + +// List calls process.Process.List. +func (c *processClient) List(ctx context.Context, req *connect.Request[process.ListRequest]) (*connect.Response[process.ListResponse], error) { + return c.list.CallUnary(ctx, req) +} + +// Connect calls process.Process.Connect. +func (c *processClient) Connect(ctx context.Context, req *connect.Request[process.ConnectRequest]) (*connect.ServerStreamForClient[process.ConnectResponse], error) { + return c.connect.CallServerStream(ctx, req) +} + +// Start calls process.Process.Start. +func (c *processClient) Start(ctx context.Context, req *connect.Request[process.StartRequest]) (*connect.ServerStreamForClient[process.StartResponse], error) { + return c.start.CallServerStream(ctx, req) +} + +// Update calls process.Process.Update. +func (c *processClient) Update(ctx context.Context, req *connect.Request[process.UpdateRequest]) (*connect.Response[process.UpdateResponse], error) { + return c.update.CallUnary(ctx, req) +} + +// StreamInput calls process.Process.StreamInput. +func (c *processClient) StreamInput(ctx context.Context) *connect.ClientStreamForClient[process.StreamInputRequest, process.StreamInputResponse] { + return c.streamInput.CallClientStream(ctx) +} + +// SendInput calls process.Process.SendInput. +func (c *processClient) SendInput(ctx context.Context, req *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) { + return c.sendInput.CallUnary(ctx, req) +} + +// SendSignal calls process.Process.SendSignal. +func (c *processClient) SendSignal(ctx context.Context, req *connect.Request[process.SendSignalRequest]) (*connect.Response[process.SendSignalResponse], error) { + return c.sendSignal.CallUnary(ctx, req) +} + +// CloseStdin calls process.Process.CloseStdin. +func (c *processClient) CloseStdin(ctx context.Context, req *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) { + return c.closeStdin.CallUnary(ctx, req) +} + +// ProcessHandler is an implementation of the process.Process service. +type ProcessHandler interface { + List(context.Context, *connect.Request[process.ListRequest]) (*connect.Response[process.ListResponse], error) + Connect(context.Context, *connect.Request[process.ConnectRequest], *connect.ServerStream[process.ConnectResponse]) error + Start(context.Context, *connect.Request[process.StartRequest], *connect.ServerStream[process.StartResponse]) error + Update(context.Context, *connect.Request[process.UpdateRequest]) (*connect.Response[process.UpdateResponse], error) + // Client input stream ensures ordering of messages + StreamInput(context.Context, *connect.ClientStream[process.StreamInputRequest]) (*connect.Response[process.StreamInputResponse], error) + SendInput(context.Context, *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) + SendSignal(context.Context, *connect.Request[process.SendSignalRequest]) (*connect.Response[process.SendSignalResponse], error) + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + CloseStdin(context.Context, *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) +} + +// NewProcessHandler builds an HTTP handler from the service implementation. It returns the path on +// which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewProcessHandler(svc ProcessHandler, opts ...connect.HandlerOption) (string, http.Handler) { + processMethods := process.File_process_process_proto.Services().ByName("Process").Methods() + processListHandler := connect.NewUnaryHandler( + ProcessListProcedure, + svc.List, + connect.WithSchema(processMethods.ByName("List")), + connect.WithHandlerOptions(opts...), + ) + processConnectHandler := connect.NewServerStreamHandler( + ProcessConnectProcedure, + svc.Connect, + connect.WithSchema(processMethods.ByName("Connect")), + connect.WithHandlerOptions(opts...), + ) + processStartHandler := connect.NewServerStreamHandler( + ProcessStartProcedure, + svc.Start, + connect.WithSchema(processMethods.ByName("Start")), + connect.WithHandlerOptions(opts...), + ) + processUpdateHandler := connect.NewUnaryHandler( + ProcessUpdateProcedure, + svc.Update, + connect.WithSchema(processMethods.ByName("Update")), + connect.WithHandlerOptions(opts...), + ) + processStreamInputHandler := connect.NewClientStreamHandler( + ProcessStreamInputProcedure, + svc.StreamInput, + connect.WithSchema(processMethods.ByName("StreamInput")), + connect.WithHandlerOptions(opts...), + ) + processSendInputHandler := connect.NewUnaryHandler( + ProcessSendInputProcedure, + svc.SendInput, + connect.WithSchema(processMethods.ByName("SendInput")), + connect.WithHandlerOptions(opts...), + ) + processSendSignalHandler := connect.NewUnaryHandler( + ProcessSendSignalProcedure, + svc.SendSignal, + connect.WithSchema(processMethods.ByName("SendSignal")), + connect.WithHandlerOptions(opts...), + ) + processCloseStdinHandler := connect.NewUnaryHandler( + ProcessCloseStdinProcedure, + svc.CloseStdin, + connect.WithSchema(processMethods.ByName("CloseStdin")), + connect.WithHandlerOptions(opts...), + ) + return "/process.Process/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case ProcessListProcedure: + processListHandler.ServeHTTP(w, r) + case ProcessConnectProcedure: + processConnectHandler.ServeHTTP(w, r) + case ProcessStartProcedure: + processStartHandler.ServeHTTP(w, r) + case ProcessUpdateProcedure: + processUpdateHandler.ServeHTTP(w, r) + case ProcessStreamInputProcedure: + processStreamInputHandler.ServeHTTP(w, r) + case ProcessSendInputProcedure: + processSendInputHandler.ServeHTTP(w, r) + case ProcessSendSignalProcedure: + processSendSignalHandler.ServeHTTP(w, r) + case ProcessCloseStdinProcedure: + processCloseStdinHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedProcessHandler returns CodeUnimplemented from all methods. +type UnimplementedProcessHandler struct{} + +func (UnimplementedProcessHandler) List(context.Context, *connect.Request[process.ListRequest]) (*connect.Response[process.ListResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.List is not implemented")) +} + +func (UnimplementedProcessHandler) Connect(context.Context, *connect.Request[process.ConnectRequest], *connect.ServerStream[process.ConnectResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Connect is not implemented")) +} + +func (UnimplementedProcessHandler) Start(context.Context, *connect.Request[process.StartRequest], *connect.ServerStream[process.StartResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Start is not implemented")) +} + +func (UnimplementedProcessHandler) Update(context.Context, *connect.Request[process.UpdateRequest]) (*connect.Response[process.UpdateResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Update is not implemented")) +} + +func (UnimplementedProcessHandler) StreamInput(context.Context, *connect.ClientStream[process.StreamInputRequest]) (*connect.Response[process.StreamInputResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.StreamInput is not implemented")) +} + +func (UnimplementedProcessHandler) SendInput(context.Context, *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendInput is not implemented")) +} + +func (UnimplementedProcessHandler) SendSignal(context.Context, *connect.Request[process.SendSignalRequest]) (*connect.Response[process.SendSignalResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendSignal is not implemented")) +} + +func (UnimplementedProcessHandler) CloseStdin(context.Context, *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.CloseStdin is not implemented")) +} diff --git a/envd/internal/shared/filesystem/entry.go b/envd/internal/shared/filesystem/entry.go new file mode 100644 index 0000000..7e0bffb --- /dev/null +++ b/envd/internal/shared/filesystem/entry.go @@ -0,0 +1,108 @@ +package filesystem + +import ( + "os" + "path/filepath" + "syscall" + "time" +) + +func GetEntryFromPath(path string) (EntryInfo, error) { + fileInfo, err := os.Lstat(path) + if err != nil { + return EntryInfo{}, err + } + + return GetEntryInfo(path, fileInfo), nil +} + +func GetEntryInfo(path string, fileInfo os.FileInfo) EntryInfo { + fileMode := fileInfo.Mode() + + var symlinkTarget *string + if fileMode&os.ModeSymlink != 0 { + // If we can't resolve the symlink target, we won't set the target + target := followSymlink(path) + symlinkTarget = &target + } + + var entryType FileType + var mode os.FileMode + + if symlinkTarget == nil { + entryType = getEntryType(fileMode) + mode = fileMode.Perm() + } else { + // If it's a symlink, we need to determine the type of the target + targetInfo, err := os.Stat(*symlinkTarget) + if err != nil { + entryType = UnknownFileType + } else { + entryType = getEntryType(targetInfo.Mode()) + mode = targetInfo.Mode().Perm() + } + } + + entry := EntryInfo{ + Name: fileInfo.Name(), + Path: path, + Type: entryType, + Size: fileInfo.Size(), + Mode: mode, + Permissions: fileMode.String(), + ModifiedTime: fileInfo.ModTime(), + SymlinkTarget: symlinkTarget, + } + + if base := getBase(fileInfo.Sys()); base != nil { + entry.AccessedTime = toTimestamp(base.Atim) + entry.CreatedTime = toTimestamp(base.Ctim) + entry.ModifiedTime = toTimestamp(base.Mtim) + entry.UID = base.Uid + entry.GID = base.Gid + } else if !fileInfo.ModTime().IsZero() { + entry.ModifiedTime = fileInfo.ModTime() + } + + return entry +} + +// getEntryType determines the type of file entry based on its mode and path. +// If the file is a symlink, it follows the symlink to determine the actual type. +func getEntryType(mode os.FileMode) FileType { + switch { + case mode.IsRegular(): + return FileFileType + case mode.IsDir(): + return DirectoryFileType + case mode&os.ModeSymlink == os.ModeSymlink: + return SymlinkFileType + default: + return UnknownFileType + } +} + +// followSymlink resolves a symbolic link to its target path. +func followSymlink(path string) string { + // Resolve symlinks + resolvedPath, err := filepath.EvalSymlinks(path) + if err != nil { + return path + } + + return resolvedPath +} + +func toTimestamp(spec syscall.Timespec) time.Time { + if spec.Sec == 0 && spec.Nsec == 0 { + return time.Time{} + } + + return time.Unix(spec.Sec, spec.Nsec) +} + +func getBase(sys any) *syscall.Stat_t { + st, _ := sys.(*syscall.Stat_t) + + return st +} diff --git a/envd/internal/shared/filesystem/entry_test.go b/envd/internal/shared/filesystem/entry_test.go new file mode 100644 index 0000000..485fb26 --- /dev/null +++ b/envd/internal/shared/filesystem/entry_test.go @@ -0,0 +1,264 @@ +package filesystem + +import ( + "os" + "os/user" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetEntryType(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create test files + regularFile := filepath.Join(tempDir, "regular.txt") + require.NoError(t, os.WriteFile(regularFile, []byte("test content"), 0o644)) + + testDir := filepath.Join(tempDir, "testdir") + require.NoError(t, os.MkdirAll(testDir, 0o755)) + + symlink := filepath.Join(tempDir, "symlink") + require.NoError(t, os.Symlink(regularFile, symlink)) + + tests := []struct { + name string + path string + expected FileType + }{ + { + name: "regular file", + path: regularFile, + expected: FileFileType, + }, + { + name: "directory", + path: testDir, + expected: DirectoryFileType, + }, + { + name: "symlink to file", + path: symlink, + expected: SymlinkFileType, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + info, err := os.Lstat(tt.path) + require.NoError(t, err) + + result := getEntryType(info.Mode()) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEntryInfoFromFileInfo_SymlinkChain(t *testing.T) { + t.Parallel() + + // Base temporary directory. On macOS this lives under /var/folders/… + // which itself is a symlink to /private/var/folders/…. + tempDir := t.TempDir() + + // Create final target + target := filepath.Join(tempDir, "target") + require.NoError(t, os.MkdirAll(target, 0o755)) + + // Create a chain: link1 → link2 → target + link2 := filepath.Join(tempDir, "link2") + require.NoError(t, os.Symlink(target, link2)) + + link1 := filepath.Join(tempDir, "link1") + require.NoError(t, os.Symlink(link2, link1)) + + // run the test + result, err := GetEntryFromPath(link1) + require.NoError(t, err) + + // verify the results + assert.Equal(t, "link1", result.Name) + assert.Equal(t, link1, result.Path) + assert.Equal(t, DirectoryFileType, result.Type) // Should resolve to final target type + assert.Contains(t, result.Permissions, "L") + + // Canonicalize the expected target path to handle macOS symlink indirections + expectedTarget, err := filepath.EvalSymlinks(link1) + require.NoError(t, err) + assert.Equal(t, expectedTarget, *result.SymlinkTarget) +} + +func TestEntryInfoFromFileInfo_DifferentPermissions(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + testCases := []struct { + name string + permissions os.FileMode + expectedMode os.FileMode + expectedString string + }{ + {"read-only", 0o444, 0o444, "-r--r--r--"}, + {"executable", 0o755, 0o755, "-rwxr-xr-x"}, + {"write-only", 0o200, 0o200, "--w-------"}, + {"no permissions", 0o000, 0o000, "----------"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + testFile := filepath.Join(tempDir, tc.name+".txt") + require.NoError(t, os.WriteFile(testFile, []byte("test"), tc.permissions)) + + result, err := GetEntryFromPath(testFile) + require.NoError(t, err) + assert.Equal(t, tc.expectedMode, result.Mode) + assert.Equal(t, tc.expectedString, result.Permissions) + }) + } +} + +func TestEntryInfoFromFileInfo_EmptyFile(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + emptyFile := filepath.Join(tempDir, "empty.txt") + require.NoError(t, os.WriteFile(emptyFile, []byte{}, 0o600)) + + result, err := GetEntryFromPath(emptyFile) + require.NoError(t, err) + + assert.Equal(t, "empty.txt", result.Name) + assert.Equal(t, int64(0), result.Size) + assert.Equal(t, os.FileMode(0o600), result.Mode) + assert.Equal(t, FileFileType, result.Type) +} + +func TestEntryInfoFromFileInfo_CyclicSymlink(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create cyclic symlink + cyclicSymlink := filepath.Join(tempDir, "cyclic") + require.NoError(t, os.Symlink(cyclicSymlink, cyclicSymlink)) + + result, err := GetEntryFromPath(cyclicSymlink) + require.NoError(t, err) + + assert.Equal(t, "cyclic", result.Name) + assert.Equal(t, cyclicSymlink, result.Path) + assert.Equal(t, UnknownFileType, result.Type) + assert.Contains(t, result.Permissions, "L") +} + +func TestEntryInfoFromFileInfo_BrokenSymlink(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create broken symlink + brokenSymlink := filepath.Join(tempDir, "broken") + require.NoError(t, os.Symlink("/nonexistent", brokenSymlink)) + + result, err := GetEntryFromPath(brokenSymlink) + require.NoError(t, err) + + assert.Equal(t, "broken", result.Name) + assert.Equal(t, brokenSymlink, result.Path) + assert.Equal(t, UnknownFileType, result.Type) + assert.Contains(t, result.Permissions, "L") + // SymlinkTarget might be empty if followSymlink fails +} + +func TestEntryInfoFromFileInfo(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create a regular file with known content and permissions + testFile := filepath.Join(tempDir, "test.txt") + testContent := []byte("Hello, World!") + require.NoError(t, os.WriteFile(testFile, testContent, 0o644)) + + // Get current user for ownership comparison + currentUser, err := user.Current() + require.NoError(t, err) + + result, err := GetEntryFromPath(testFile) + require.NoError(t, err) + + // Basic assertions + assert.Equal(t, "test.txt", result.Name) + assert.Equal(t, testFile, result.Path) + assert.Equal(t, int64(len(testContent)), result.Size) + assert.Equal(t, FileFileType, result.Type) + assert.Equal(t, os.FileMode(0o644), result.Mode) + assert.Contains(t, result.Permissions, "-rw-r--r--") + assert.Equal(t, currentUser.Uid, strconv.Itoa(int(result.UID))) + assert.Equal(t, currentUser.Gid, strconv.Itoa(int(result.GID))) + assert.NotNil(t, result.ModifiedTime) + assert.Empty(t, result.SymlinkTarget) + + // Check that modified time is reasonable (within last minute) + modTime := result.ModifiedTime + assert.WithinDuration(t, time.Now(), modTime, time.Minute) +} + +func TestEntryInfoFromFileInfo_Directory(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + testDir := filepath.Join(tempDir, "testdir") + require.NoError(t, os.MkdirAll(testDir, 0o755)) + + result, err := GetEntryFromPath(testDir) + require.NoError(t, err) + + assert.Equal(t, "testdir", result.Name) + assert.Equal(t, testDir, result.Path) + assert.Equal(t, DirectoryFileType, result.Type) + assert.Equal(t, os.FileMode(0o755), result.Mode) + assert.Equal(t, "drwxr-xr-x", result.Permissions) + assert.Empty(t, result.SymlinkTarget) +} + +func TestEntryInfoFromFileInfo_Symlink(t *testing.T) { + t.Parallel() + + // Base temporary directory. On macOS this lives under /var/folders/… + // which itself is a symlink to /private/var/folders/…. + tempDir := t.TempDir() + + // Create target file + targetFile := filepath.Join(tempDir, "target.txt") + require.NoError(t, os.WriteFile(targetFile, []byte("target content"), 0o644)) + + // Create symlink + symlinkPath := filepath.Join(tempDir, "symlink") + require.NoError(t, os.Symlink(targetFile, symlinkPath)) + + // Use Lstat to get symlink info (not the target) + result, err := GetEntryFromPath(symlinkPath) + require.NoError(t, err) + + assert.Equal(t, "symlink", result.Name) + assert.Equal(t, symlinkPath, result.Path) + assert.Equal(t, FileFileType, result.Type) // Should resolve to target type + assert.Contains(t, result.Permissions, "L") // Should show as symlink in permissions + + // Canonicalize the expected target path to handle macOS /var → /private/var symlink + expectedTarget, err := filepath.EvalSymlinks(symlinkPath) + require.NoError(t, err) + assert.Equal(t, expectedTarget, *result.SymlinkTarget) +} diff --git a/envd/internal/shared/filesystem/model.go b/envd/internal/shared/filesystem/model.go new file mode 100644 index 0000000..b586ca1 --- /dev/null +++ b/envd/internal/shared/filesystem/model.go @@ -0,0 +1,30 @@ +package filesystem + +import ( + "os" + "time" +) + +type EntryInfo struct { + Name string + Type FileType + Path string + Size int64 + Mode os.FileMode + Permissions string + UID uint32 + GID uint32 + AccessedTime time.Time + CreatedTime time.Time + ModifiedTime time.Time + SymlinkTarget *string +} + +type FileType int32 + +const ( + UnknownFileType FileType = 0 + FileFileType FileType = 1 + DirectoryFileType FileType = 2 + SymlinkFileType FileType = 3 +) diff --git a/envd/internal/shared/id/id.go b/envd/internal/shared/id/id.go new file mode 100644 index 0000000..4524261 --- /dev/null +++ b/envd/internal/shared/id/id.go @@ -0,0 +1,164 @@ +package id + +import ( + "errors" + "fmt" + "maps" + "regexp" + "slices" + "strings" + + "github.com/dchest/uniuri" + "github.com/google/uuid" +) + +var ( + caseInsensitiveAlphabet = []byte("abcdefghijklmnopqrstuvwxyz1234567890") + identifierRegex = regexp.MustCompile(`^[a-z0-9-_]+$`) + tagRegex = regexp.MustCompile(`^[a-z0-9-_.]+$`) + sandboxIDRegex = regexp.MustCompile(`^[a-z0-9]+$`) +) + +const ( + DefaultTag = "default" + TagSeparator = ":" + NamespaceSeparator = "/" +) + +func Generate() string { + return uniuri.NewLenChars(uniuri.UUIDLen, caseInsensitiveAlphabet) +} + +// ValidateSandboxID checks that a sandbox ID contains only lowercase alphanumeric characters. +func ValidateSandboxID(sandboxID string) error { + if !sandboxIDRegex.MatchString(sandboxID) { + return fmt.Errorf("invalid sandbox ID: %q", sandboxID) + } + + return nil +} + +func cleanAndValidate(value, name string, re *regexp.Regexp) (string, error) { + cleaned := strings.ToLower(strings.TrimSpace(value)) + if !re.MatchString(cleaned) { + return "", fmt.Errorf("invalid %s: %s", name, value) + } + + return cleaned, nil +} + +func validateTag(tag string) (string, error) { + cleanedTag, err := cleanAndValidate(tag, "tag", tagRegex) + if err != nil { + return "", err + } + + // Prevent tags from being a UUID + _, err = uuid.Parse(cleanedTag) + if err == nil { + return "", errors.New("tag cannot be a UUID") + } + + return cleanedTag, nil +} + +func ValidateAndDeduplicateTags(tags []string) ([]string, error) { + seen := make(map[string]struct{}) + + for _, tag := range tags { + cleanedTag, err := validateTag(tag) + if err != nil { + return nil, fmt.Errorf("invalid tag '%s': %w", tag, err) + } + + seen[cleanedTag] = struct{}{} + } + + return slices.Collect(maps.Keys(seen)), nil +} + +// SplitIdentifier splits "namespace/alias" into its parts. +// Returns nil namespace for bare aliases, pointer for explicit namespace. +func SplitIdentifier(identifier string) (namespace *string, alias string) { + before, after, found := strings.Cut(identifier, NamespaceSeparator) + if !found { + return nil, before + } + + return &before, after +} + +// ParseName parses and validates "namespace/alias:tag" or "alias:tag". +// Returns the cleaned identifier (namespace/alias or alias) and optional tag. +// All components are validated and normalized (lowercase, trimmed). +func ParseName(input string) (identifier string, tag *string, err error) { + input = strings.TrimSpace(input) + + // Extract raw parts + identifierPart, tagPart, hasTag := strings.Cut(input, TagSeparator) + namespacePart, aliasPart := SplitIdentifier(identifierPart) + + // Validate tag + if hasTag { + validated, err := cleanAndValidate(tagPart, "tag", tagRegex) + if err != nil { + return "", nil, err + } + if !strings.EqualFold(validated, DefaultTag) { + tag = &validated + } + } + + // Validate namespace + if namespacePart != nil { + validated, err := cleanAndValidate(*namespacePart, "namespace", identifierRegex) + if err != nil { + return "", nil, err + } + namespacePart = &validated + } + + // Validate alias + aliasPart, err = cleanAndValidate(aliasPart, "template ID", identifierRegex) + if err != nil { + return "", nil, err + } + + // Build identifier + if namespacePart != nil { + identifier = WithNamespace(*namespacePart, aliasPart) + } else { + identifier = aliasPart + } + + return identifier, tag, nil +} + +// WithTag returns the identifier with the given tag appended (e.g. "templateID:tag"). +func WithTag(identifier, tag string) string { + return identifier + TagSeparator + tag +} + +// WithNamespace returns identifier with the given namespace prefix. +func WithNamespace(namespace, alias string) string { + return namespace + NamespaceSeparator + alias +} + +// ExtractAlias returns just the alias portion from an identifier (namespace/alias or alias). +func ExtractAlias(identifier string) string { + _, alias := SplitIdentifier(identifier) + + return alias +} + +// ValidateNamespaceMatchesTeam checks if an explicit namespace in the identifier matches the team's slug. +// Returns an error if the namespace doesn't match. +// If the identifier has no explicit namespace, returns nil (valid). +func ValidateNamespaceMatchesTeam(identifier, teamSlug string) error { + namespace, _ := SplitIdentifier(identifier) + if namespace != nil && *namespace != teamSlug { + return fmt.Errorf("namespace '%s' must match your team '%s'", *namespace, teamSlug) + } + + return nil +} diff --git a/envd/internal/shared/id/id_test.go b/envd/internal/shared/id/id_test.go new file mode 100644 index 0000000..5155c75 --- /dev/null +++ b/envd/internal/shared/id/id_test.go @@ -0,0 +1,380 @@ +package id + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/utils" +) + +func TestParseName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantIdentifier string + wantTag *string + wantErr bool + }{ + { + name: "bare alias only", + input: "my-template", + wantIdentifier: "my-template", + wantTag: nil, + }, + { + name: "alias with tag", + input: "my-template:v1", + wantIdentifier: "my-template", + wantTag: utils.ToPtr("v1"), + }, + { + name: "namespace and alias", + input: "acme/my-template", + wantIdentifier: "acme/my-template", + wantTag: nil, + }, + { + name: "namespace, alias and tag", + input: "acme/my-template:v1", + wantIdentifier: "acme/my-template", + wantTag: utils.ToPtr("v1"), + }, + { + name: "namespace with hyphens", + input: "my-team/my-template:prod", + wantIdentifier: "my-team/my-template", + wantTag: utils.ToPtr("prod"), + }, + { + name: "default tag normalized to nil", + input: "my-template:default", + wantIdentifier: "my-template", + wantTag: nil, + }, + { + name: "uppercase converted to lowercase", + input: "MyTemplate:Prod", + wantIdentifier: "mytemplate", + wantTag: utils.ToPtr("prod"), + }, + { + name: "whitespace trimmed", + input: " my-template : v1 ", + wantIdentifier: "my-template", + wantTag: utils.ToPtr("v1"), + }, + { + name: "invalid - empty namespace", + input: "/my-template", + wantErr: true, + }, + { + name: "invalid - empty tag after colon", + input: "my-template:", + wantErr: true, + }, + { + name: "invalid - special characters in alias", + input: "my template!", + wantErr: true, + }, + { + name: "invalid - special characters in namespace", + input: "my team!/my-template", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotIdentifier, gotTag, err := ParseName(tt.input) + + if tt.wantErr { + require.Error(t, err, "Expected ParseName() to return error, got") + + return + } + + require.NoError(t, err, "Expected ParseName() not to return error, got: %v", err) + assert.Equal(t, tt.wantIdentifier, gotIdentifier, "ParseName() identifier = %v, want %v", gotIdentifier, tt.wantIdentifier) + assert.Equal(t, tt.wantTag, gotTag, "ParseName() tag = %v, want %v", utils.Sprintp(gotTag), utils.Sprintp(tt.wantTag)) + }) + } +} + +func TestWithNamespace(t *testing.T) { + t.Parallel() + + got := WithNamespace("acme", "my-template") + want := "acme/my-template" + assert.Equal(t, want, got, "WithNamespace() = %q, want %q", got, want) +} + +func TestSplitIdentifier(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + identifier string + wantNamespace *string + wantAlias string + }{ + { + name: "bare alias", + identifier: "my-template", + wantNamespace: nil, + wantAlias: "my-template", + }, + { + name: "with namespace", + identifier: "acme/my-template", + wantNamespace: ptrStr("acme"), + wantAlias: "my-template", + }, + { + name: "empty namespace prefix", + identifier: "/my-template", + wantNamespace: ptrStr(""), + wantAlias: "my-template", + }, + { + name: "multiple slashes - only first split", + identifier: "a/b/c", + wantNamespace: ptrStr("a"), + wantAlias: "b/c", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotNamespace, gotAlias := SplitIdentifier(tt.identifier) + + if tt.wantNamespace == nil { + assert.Nil(t, gotNamespace) + } else { + require.NotNil(t, gotNamespace) + assert.Equal(t, *tt.wantNamespace, *gotNamespace) + } + + assert.Equal(t, tt.wantAlias, gotAlias) + }) + } +} + +func ptrStr(s string) *string { + return &s +} + +func TestValidateAndDeduplicateTags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tags []string + want []string + wantErr bool + }{ + { + name: "single valid tag", + tags: []string{"v1"}, + want: []string{"v1"}, + wantErr: false, + }, + { + name: "multiple unique tags", + tags: []string{"v1", "prod", "latest"}, + want: []string{"v1", "prod", "latest"}, + wantErr: false, + }, + { + name: "duplicate tags deduplicated", + tags: []string{"v1", "V1", "v1"}, + want: []string{"v1"}, + wantErr: false, + }, + { + name: "tags with dots and underscores", + tags: []string{"v1.0", "v1_1"}, + want: []string{"v1.0", "v1_1"}, + wantErr: false, + }, + { + name: "invalid - UUID tag rejected", + tags: []string{"550e8400-e29b-41d4-a716-446655440000"}, + wantErr: true, + }, + { + name: "invalid - special characters", + tags: []string{"v1!", "v2@"}, + wantErr: true, + }, + { + name: "empty list returns empty", + tags: []string{}, + want: []string{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := ValidateAndDeduplicateTags(tt.tags) + + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + assert.ElementsMatch(t, tt.want, got) + }) + } +} + +func TestValidateSandboxID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "canonical sandbox ID", + input: "i1a2b3c4d5e6f7g8h9j0k", + wantErr: false, + }, + { + name: "short alphanumeric", + input: "abc123", + wantErr: false, + }, + { + name: "all digits", + input: "1234567890", + wantErr: false, + }, + { + name: "all lowercase letters", + input: "abcdefghijklmnopqrst", + wantErr: false, + }, + { + name: "invalid - empty", + input: "", + wantErr: true, + }, + { + name: "invalid - contains colon (Redis separator)", + input: "abc:def", + wantErr: true, + }, + { + name: "invalid - contains open brace (Redis hash slot)", + input: "abc{def", + wantErr: true, + }, + { + name: "invalid - contains close brace (Redis hash slot)", + input: "abc}def", + wantErr: true, + }, + { + name: "invalid - contains newline", + input: "abc\ndef", + wantErr: true, + }, + { + name: "invalid - contains space", + input: "abc def", + wantErr: true, + }, + { + name: "invalid - contains hyphen", + input: "abc-def", + wantErr: true, + }, + { + name: "invalid - contains uppercase", + input: "abcDEF", + wantErr: true, + }, + { + name: "invalid - contains slash", + input: "abc/def", + wantErr: true, + }, + { + name: "invalid - contains null byte", + input: "abc\x00def", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateSandboxID(tt.input) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateNamespaceMatchesTeam(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + identifier string + teamSlug string + wantErr bool + }{ + { + name: "bare alias - no namespace", + identifier: "my-template", + teamSlug: "acme", + wantErr: false, + }, + { + name: "matching namespace", + identifier: "acme/my-template", + teamSlug: "acme", + wantErr: false, + }, + { + name: "mismatched namespace", + identifier: "other-team/my-template", + teamSlug: "acme", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateNamespaceMatchesTeam(tt.identifier, tt.teamSlug) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/envd/internal/shared/keys/constants.go b/envd/internal/shared/keys/constants.go new file mode 100644 index 0000000..4d00bdb --- /dev/null +++ b/envd/internal/shared/keys/constants.go @@ -0,0 +1,6 @@ +package keys + +const ( + ApiKeyPrefix = "wrn_" + AccessTokenPrefix = "sk_wrn_" +) diff --git a/envd/internal/shared/keys/hashing.go b/envd/internal/shared/keys/hashing.go new file mode 100644 index 0000000..a5572f2 --- /dev/null +++ b/envd/internal/shared/keys/hashing.go @@ -0,0 +1,5 @@ +package keys + +type Hasher interface { + Hash(key []byte) string +} diff --git a/envd/internal/shared/keys/hmac_sha256.go b/envd/internal/shared/keys/hmac_sha256.go new file mode 100644 index 0000000..b0957a4 --- /dev/null +++ b/envd/internal/shared/keys/hmac_sha256.go @@ -0,0 +1,25 @@ +package keys + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +type HMACSha256Hashing struct { + key []byte +} + +func NewHMACSHA256Hashing(key []byte) *HMACSha256Hashing { + return &HMACSha256Hashing{key: key} +} + +func (h *HMACSha256Hashing) Hash(content []byte) (string, error) { + mac := hmac.New(sha256.New, h.key) + _, err := mac.Write(content) + if err != nil { + return "", err + } + + return hex.EncodeToString(mac.Sum(nil)), nil +} diff --git a/envd/internal/shared/keys/hmac_sha256_test.go b/envd/internal/shared/keys/hmac_sha256_test.go new file mode 100644 index 0000000..f592cc3 --- /dev/null +++ b/envd/internal/shared/keys/hmac_sha256_test.go @@ -0,0 +1,74 @@ +package keys + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHMACSha256Hashing_ValidHash(t *testing.T) { + t.Parallel() + key := []byte("test-key") + hasher := NewHMACSHA256Hashing(key) + content := []byte("hello world") + expectedHash := "18c4b268f0bbf8471eda56af3e70b1d4613d734dc538b4940b59931c412a1591" + actualHash, err := hasher.Hash(content) + require.NoError(t, err) + + if actualHash != expectedHash { + t.Errorf("expected %s, got %s", expectedHash, actualHash) + } +} + +func TestHMACSha256Hashing_EmptyContent(t *testing.T) { + t.Parallel() + key := []byte("test-key") + hasher := NewHMACSHA256Hashing(key) + content := []byte("") + expectedHash := "2711cc23e9ab1b8a9bc0fe991238da92671624a9ebdaf1c1abec06e7e9a14f9b" + actualHash, err := hasher.Hash(content) + require.NoError(t, err) + + if actualHash != expectedHash { + t.Errorf("expected %s, got %s", expectedHash, actualHash) + } +} + +func TestHMACSha256Hashing_DifferentKey(t *testing.T) { + t.Parallel() + key := []byte("test-key") + hasher := NewHMACSHA256Hashing(key) + differentKeyHasher := NewHMACSHA256Hashing([]byte("different-key")) + content := []byte("hello world") + + hashWithOriginalKey, err := hasher.Hash(content) + require.NoError(t, err) + + hashWithDifferentKey, err := differentKeyHasher.Hash(content) + require.NoError(t, err) + + if hashWithOriginalKey == hashWithDifferentKey { + t.Errorf("hashes with different keys should not match") + } +} + +func TestHMACSha256Hashing_IdenticalResult(t *testing.T) { + t.Parallel() + key := []byte("placeholder-hashing-key") + content := []byte("test content for hashing") + + mac := hmac.New(sha256.New, key) + mac.Write(content) + expectedResult := hex.EncodeToString(mac.Sum(nil)) + + hasher := NewHMACSHA256Hashing(key) + actualResult, err := hasher.Hash(content) + require.NoError(t, err) + + if actualResult != expectedResult { + t.Errorf("expected %s, got %s", expectedResult, actualResult) + } +} diff --git a/envd/internal/shared/keys/key.go b/envd/internal/shared/keys/key.go new file mode 100644 index 0000000..24379a0 --- /dev/null +++ b/envd/internal/shared/keys/key.go @@ -0,0 +1,99 @@ +package keys + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" +) + +const ( + identifierValueSuffixLength = 4 + identifierValuePrefixLength = 2 + + keyLength = 20 +) + +var hasher Hasher = NewSHA256Hashing() + +type Key struct { + PrefixedRawValue string + HashedValue string + Masked MaskedIdentifier +} + +type MaskedIdentifier struct { + Prefix string + ValueLength int + MaskedValuePrefix string + MaskedValueSuffix string +} + +// MaskKey returns identifier masking properties in accordance to the OpenAPI response spec +func MaskKey(prefix, value string) (MaskedIdentifier, error) { + valueLength := len(value) + + suffixOffset := valueLength - identifierValueSuffixLength + prefixOffset := identifierValuePrefixLength + + if suffixOffset < 0 { + return MaskedIdentifier{}, fmt.Errorf("mask value length is less than identifier suffix length (%d)", identifierValueSuffixLength) + } + + if suffixOffset == 0 { + return MaskedIdentifier{}, fmt.Errorf("mask value length is equal to identifier suffix length (%d), which would expose the entire identifier in the mask", identifierValueSuffixLength) + } + + // cap prefixOffset by suffixOffset to prevent overlap with the suffix. + if prefixOffset > suffixOffset { + prefixOffset = suffixOffset + } + + maskPrefix := value[:prefixOffset] + maskSuffix := value[suffixOffset:] + + maskedIdentifierProperties := MaskedIdentifier{ + Prefix: prefix, + ValueLength: valueLength, + MaskedValuePrefix: maskPrefix, + MaskedValueSuffix: maskSuffix, + } + + return maskedIdentifierProperties, nil +} + +func GenerateKey(prefix string) (Key, error) { + keyBytes := make([]byte, keyLength) + + _, err := rand.Read(keyBytes) + if err != nil { + return Key{}, err + } + + generatedIdentifier := hex.EncodeToString(keyBytes) + + mask, err := MaskKey(prefix, generatedIdentifier) + if err != nil { + return Key{}, err + } + + return Key{ + PrefixedRawValue: prefix + generatedIdentifier, + HashedValue: hasher.Hash(keyBytes), + Masked: mask, + }, nil +} + +func VerifyKey(prefix string, key string) (string, error) { + if !strings.HasPrefix(key, prefix) { + return "", fmt.Errorf("invalid key prefix") + } + + keyValue := key[len(prefix):] + keyBytes, err := hex.DecodeString(keyValue) + if err != nil { + return "", fmt.Errorf("invalid key") + } + + return hasher.Hash(keyBytes), nil +} diff --git a/envd/internal/shared/keys/key_test.go b/envd/internal/shared/keys/key_test.go new file mode 100644 index 0000000..b06b9ea --- /dev/null +++ b/envd/internal/shared/keys/key_test.go @@ -0,0 +1,160 @@ +package keys + +import ( + "fmt" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMaskKey(t *testing.T) { + t.Parallel() + t.Run("succeeds: value longer than suffix length", func(t *testing.T) { + t.Parallel() + masked, err := MaskKey("test_", "1234567890") + require.NoError(t, err) + assert.Equal(t, "test_", masked.Prefix) + assert.Equal(t, "12", masked.MaskedValuePrefix) + assert.Equal(t, "7890", masked.MaskedValueSuffix) + }) + + t.Run("succeeds: empty prefix, value longer than suffix length", func(t *testing.T) { + t.Parallel() + masked, err := MaskKey("", "1234567890") + require.NoError(t, err) + assert.Empty(t, masked.Prefix) + assert.Equal(t, "12", masked.MaskedValuePrefix) + assert.Equal(t, "7890", masked.MaskedValueSuffix) + }) + + t.Run("error: value length less than suffix length", func(t *testing.T) { + t.Parallel() + _, err := MaskKey("test", "123") + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("mask value length is less than identifier suffix length (%d)", identifierValueSuffixLength)) + }) + + t.Run("error: value length equals suffix length", func(t *testing.T) { + t.Parallel() + _, err := MaskKey("test", "1234") + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("mask value length is equal to identifier suffix length (%d), which would expose the entire identifier in the mask", identifierValueSuffixLength)) + }) +} + +func TestGenerateKey(t *testing.T) { + t.Parallel() + keyLength := 40 + + t.Run("succeeds", func(t *testing.T) { + t.Parallel() + key, err := GenerateKey("test_") + require.NoError(t, err) + assert.Regexp(t, "^test_.*", key.PrefixedRawValue) + assert.Equal(t, "test_", key.Masked.Prefix) + assert.Equal(t, keyLength, key.Masked.ValueLength) + assert.Regexp(t, "^[0-9a-f]{"+strconv.Itoa(identifierValuePrefixLength)+"}$", key.Masked.MaskedValuePrefix) + assert.Regexp(t, "^[0-9a-f]{"+strconv.Itoa(identifierValueSuffixLength)+"}$", key.Masked.MaskedValueSuffix) + assert.Regexp(t, "^\\$sha256\\$.*", key.HashedValue) + }) + + t.Run("no prefix", func(t *testing.T) { + t.Parallel() + key, err := GenerateKey("") + require.NoError(t, err) + assert.Regexp(t, "^[0-9a-f]{"+strconv.Itoa(keyLength)+"}$", key.PrefixedRawValue) + assert.Empty(t, key.Masked.Prefix) + assert.Equal(t, keyLength, key.Masked.ValueLength) + assert.Regexp(t, "^[0-9a-f]{"+strconv.Itoa(identifierValuePrefixLength)+"}$", key.Masked.MaskedValuePrefix) + assert.Regexp(t, "^[0-9a-f]{"+strconv.Itoa(identifierValueSuffixLength)+"}$", key.Masked.MaskedValueSuffix) + assert.Regexp(t, "^\\$sha256\\$.*", key.HashedValue) + }) +} + +func TestGetMaskedIdentifierProperties(t *testing.T) { + t.Parallel() + type testCase struct { + name string + prefix string + value string + expectedResult MaskedIdentifier + expectedErrString string + } + + testCases := []testCase{ + // --- ERROR CASES (value's length <= identifierValueSuffixLength) --- + { + name: "error: value length < suffix length (3 vs 4)", + prefix: "pk_", + value: "abc", + expectedResult: MaskedIdentifier{}, + expectedErrString: fmt.Sprintf("mask value length is less than identifier suffix length (%d)", identifierValueSuffixLength), + }, + { + name: "error: value length == suffix length (4 vs 4)", + prefix: "sk_", + value: "abcd", + expectedResult: MaskedIdentifier{}, + expectedErrString: fmt.Sprintf("mask value length is equal to identifier suffix length (%d), which would expose the entire identifier in the mask", identifierValueSuffixLength), + }, + { + name: "error: value length < suffix length (0 vs 4, empty value)", + prefix: "err_", + value: "", + expectedResult: MaskedIdentifier{}, + expectedErrString: fmt.Sprintf("mask value length is less than identifier suffix length (%d)", identifierValueSuffixLength), + }, + + // --- SUCCESS CASES (value's length > identifierValueSuffixLength) --- + { + name: "success: value long (10), prefix val len fully used", + prefix: "pk_", + value: "abcdefghij", + expectedResult: MaskedIdentifier{ + Prefix: "pk_", + ValueLength: 10, + MaskedValuePrefix: "ab", + MaskedValueSuffix: "ghij", + }, + }, + { + name: "success: value medium (5), prefix val len truncated by overlap", + prefix: "", + value: "abcde", + expectedResult: MaskedIdentifier{ + Prefix: "", + ValueLength: 5, + MaskedValuePrefix: "a", + MaskedValueSuffix: "bcde", + }, + }, + { + name: "success: value medium (6), prefix val len fits exactly", + prefix: "pk_", + value: "abcdef", + expectedResult: MaskedIdentifier{ + Prefix: "pk_", + ValueLength: 6, + MaskedValuePrefix: "ab", + MaskedValueSuffix: "cdef", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result, err := MaskKey(tc.prefix, tc.value) + + if tc.expectedErrString != "" { + require.EqualError(t, err, tc.expectedErrString) + assert.Equal(t, tc.expectedResult, result) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectedResult, result) + } + }) + } +} diff --git a/envd/internal/shared/keys/sha256.go b/envd/internal/shared/keys/sha256.go new file mode 100644 index 0000000..49a3683 --- /dev/null +++ b/envd/internal/shared/keys/sha256.go @@ -0,0 +1,30 @@ +package keys + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" +) + +type Sha256Hashing struct{} + +func NewSHA256Hashing() *Sha256Hashing { + return &Sha256Hashing{} +} + +func (h *Sha256Hashing) Hash(key []byte) string { + hashBytes := sha256.Sum256(key) + + hash64 := base64.RawStdEncoding.EncodeToString(hashBytes[:]) + + return fmt.Sprintf( + "$sha256$%s", + hash64, + ) +} + +func (h *Sha256Hashing) HashWithoutPrefix(key []byte) string { + hashBytes := sha256.Sum256(key) + + return base64.RawStdEncoding.EncodeToString(hashBytes[:]) +} diff --git a/envd/internal/shared/keys/sha256_test.go b/envd/internal/shared/keys/sha256_test.go new file mode 100644 index 0000000..266e430 --- /dev/null +++ b/envd/internal/shared/keys/sha256_test.go @@ -0,0 +1,15 @@ +package keys + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSHA256Hashing(t *testing.T) { + t.Parallel() + hasher := NewSHA256Hashing() + + hashed := hasher.Hash([]byte("test")) + assert.Regexp(t, "^\\$sha256\\$.*", hashed) +} diff --git a/envd/internal/shared/keys/sha512.go b/envd/internal/shared/keys/sha512.go new file mode 100644 index 0000000..8b8e463 --- /dev/null +++ b/envd/internal/shared/keys/sha512.go @@ -0,0 +1,20 @@ +package keys + +import ( + "crypto/sha512" + "encoding/hex" +) + +// HashAccessToken computes the SHA-512 hash of an access token. +func HashAccessToken(token string) string { + h := sha512.Sum512([]byte(token)) + + return hex.EncodeToString(h[:]) +} + +// HashAccessTokenBytes computes the SHA-512 hash of an access token from bytes. +func HashAccessTokenBytes(token []byte) string { + h := sha512.Sum512(token) + + return hex.EncodeToString(h[:]) +} diff --git a/envd/internal/shared/smap/smap.go b/envd/internal/shared/smap/smap.go new file mode 100644 index 0000000..2937ef9 --- /dev/null +++ b/envd/internal/shared/smap/smap.go @@ -0,0 +1,47 @@ +package smap + +import ( + cmap "github.com/orcaman/concurrent-map/v2" +) + +type Map[V any] struct { + m cmap.ConcurrentMap[string, V] +} + +func New[V any]() *Map[V] { + return &Map[V]{ + m: cmap.New[V](), + } +} + +func (m *Map[V]) Remove(key string) { + m.m.Remove(key) +} + +func (m *Map[V]) Get(key string) (V, bool) { + return m.m.Get(key) +} + +func (m *Map[V]) Insert(key string, value V) { + m.m.Set(key, value) +} + +func (m *Map[V]) Upsert(key string, value V, cb cmap.UpsertCb[V]) V { + return m.m.Upsert(key, value, cb) +} + +func (m *Map[V]) InsertIfAbsent(key string, value V) bool { + return m.m.SetIfAbsent(key, value) +} + +func (m *Map[V]) Items() map[string]V { + return m.m.Items() +} + +func (m *Map[V]) RemoveCb(key string, cb func(key string, v V, exists bool) bool) bool { + return m.m.RemoveCb(key, cb) +} + +func (m *Map[V]) Count() int { + return m.m.Count() +} diff --git a/envd/internal/shared/utils/ptr.go b/envd/internal/shared/utils/ptr.go new file mode 100644 index 0000000..ec23908 --- /dev/null +++ b/envd/internal/shared/utils/ptr.go @@ -0,0 +1,43 @@ +package utils + +import "fmt" + +func ToPtr[T any](v T) *T { + return &v +} + +func FromPtr[T any](s *T) T { + if s == nil { + var zero T + + return zero + } + + return *s +} + +func Sprintp[T any](s *T) string { + if s == nil { + return "" + } + + return fmt.Sprintf("%v", *s) +} + +func DerefOrDefault[T any](s *T, defaultValue T) T { + if s == nil { + return defaultValue + } + + return *s +} + +func CastPtr[S any, T any](s *S, castFunc func(S) T) *T { + if s == nil { + return nil + } + + t := castFunc(*s) + + return &t +} diff --git a/envd/internal/utils/atomic.go b/envd/internal/utils/atomic.go new file mode 100644 index 0000000..d34d6af --- /dev/null +++ b/envd/internal/utils/atomic.go @@ -0,0 +1,27 @@ +package utils + +import ( + "sync" +) + +type AtomicMax struct { + val int64 + mu sync.Mutex +} + +func NewAtomicMax() *AtomicMax { + return &AtomicMax{} +} + +func (a *AtomicMax) SetToGreater(newValue int64) bool { + a.mu.Lock() + defer a.mu.Unlock() + + if a.val > newValue { + return false + } + + a.val = newValue + + return true +} diff --git a/envd/internal/utils/atomic_test.go b/envd/internal/utils/atomic_test.go new file mode 100644 index 0000000..206c586 --- /dev/null +++ b/envd/internal/utils/atomic_test.go @@ -0,0 +1,76 @@ +package utils + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAtomicMax_NewAtomicMax(t *testing.T) { + t.Parallel() + am := NewAtomicMax() + require.NotNil(t, am) + require.Equal(t, int64(0), am.val) +} + +func TestAtomicMax_SetToGreater_InitialValue(t *testing.T) { + t.Parallel() + am := NewAtomicMax() + + // Should succeed when newValue > current + assert.True(t, am.SetToGreater(10)) + assert.Equal(t, int64(10), am.val) +} + +func TestAtomicMax_SetToGreater_EqualValue(t *testing.T) { + t.Parallel() + am := NewAtomicMax() + am.val = 10 + + // Should succeed when newValue > current + assert.True(t, am.SetToGreater(20)) + assert.Equal(t, int64(20), am.val) +} + +func TestAtomicMax_SetToGreater_GreaterValue(t *testing.T) { + t.Parallel() + am := NewAtomicMax() + am.val = 10 + + // Should fail when newValue < current, keeping the max value + assert.False(t, am.SetToGreater(5)) + assert.Equal(t, int64(10), am.val) +} + +func TestAtomicMax_SetToGreater_NegativeValues(t *testing.T) { + t.Parallel() + am := NewAtomicMax() + am.val = -5 + + assert.True(t, am.SetToGreater(-2)) + assert.Equal(t, int64(-2), am.val) +} + +func TestAtomicMax_SetToGreater_Concurrent(t *testing.T) { + t.Parallel() + am := NewAtomicMax() + var wg sync.WaitGroup + + // Run 100 goroutines trying to update the value concurrently + numGoroutines := 100 + wg.Add(numGoroutines) + + for i := range numGoroutines { + go func(val int64) { + defer wg.Done() + am.SetToGreater(val) + }(int64(i)) + } + + wg.Wait() + + // The final value should be 99 (the maximum value) + assert.Equal(t, int64(99), am.val) +} diff --git a/envd/internal/utils/map.go b/envd/internal/utils/map.go new file mode 100644 index 0000000..256b44b --- /dev/null +++ b/envd/internal/utils/map.go @@ -0,0 +1,51 @@ +package utils + +import "sync" + +type Map[K comparable, V any] struct { + m sync.Map +} + +func NewMap[K comparable, V any]() *Map[K, V] { + return &Map[K, V]{ + m: sync.Map{}, + } +} + +func (m *Map[K, V]) Delete(key K) { + m.m.Delete(key) +} + +func (m *Map[K, V]) Load(key K) (value V, ok bool) { + v, ok := m.m.Load(key) + if !ok { + return value, ok + } + + return v.(V), ok +} + +func (m *Map[K, V]) LoadAndDelete(key K) (value V, loaded bool) { + v, loaded := m.m.LoadAndDelete(key) + if !loaded { + return value, loaded + } + + return v.(V), loaded +} + +func (m *Map[K, V]) LoadOrStore(key K, value V) (actual V, loaded bool) { + a, loaded := m.m.LoadOrStore(key, value) + + return a.(V), loaded +} + +func (m *Map[K, V]) Range(f func(key K, value V) bool) { + m.m.Range(func(key, value any) bool { + return f(key.(K), value.(V)) + }) +} + +func (m *Map[K, V]) Store(key K, value V) { + m.m.Store(key, value) +} diff --git a/envd/internal/utils/multipart.go b/envd/internal/utils/multipart.go new file mode 100644 index 0000000..a03bfd9 --- /dev/null +++ b/envd/internal/utils/multipart.go @@ -0,0 +1,43 @@ +package utils + +import ( + "errors" + "mime" + "mime/multipart" +) + +// CustomPart is a wrapper around multipart.Part that overloads the FileName method +type CustomPart struct { + *multipart.Part +} + +// FileNameWithPath returns the filename parameter of the Part's Content-Disposition header. +// This method borrows from the original FileName method implementation but returns the full +// filename without using `filepath.Base`. +func (p *CustomPart) FileNameWithPath() (string, error) { + dispositionParams, err := p.parseContentDisposition() + if err != nil { + return "", err + } + filename, ok := dispositionParams["filename"] + if !ok { + return "", errors.New("filename not found in Content-Disposition header") + } + + return filename, nil +} + +func (p *CustomPart) parseContentDisposition() (map[string]string, error) { + v := p.Header.Get("Content-Disposition") + _, dispositionParams, err := mime.ParseMediaType(v) + if err != nil { + return nil, err + } + + return dispositionParams, nil +} + +// NewCustomPart creates a new CustomPart from a multipart.Part +func NewCustomPart(part *multipart.Part) *CustomPart { + return &CustomPart{Part: part} +} diff --git a/envd/internal/utils/rfsnotify.go b/envd/internal/utils/rfsnotify.go new file mode 100644 index 0000000..9bfdbec --- /dev/null +++ b/envd/internal/utils/rfsnotify.go @@ -0,0 +1,12 @@ +package utils + +import "path/filepath" + +// FsnotifyPath creates an optionally recursive path for fsnotify/fsnotify internal implementation +func FsnotifyPath(path string, recursive bool) string { + if recursive { + return filepath.Join(path, "...") + } + + return path +} diff --git a/envd/main.go b/envd/main.go index e69de29..8975cd3 100644 --- a/envd/main.go +++ b/envd/main.go @@ -0,0 +1,290 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "connectrpc.com/authn" + connectcors "connectrpc.com/cors" + "github.com/go-chi/chi/v5" + "github.com/rs/cors" + + "git.omukk.dev/wrenn/sandbox/envd/internal/api" + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/host" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + publicport "git.omukk.dev/wrenn/sandbox/envd/internal/port" + "git.omukk.dev/wrenn/sandbox/envd/internal/services/cgroups" + filesystemRpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/filesystem" + processRpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/process" + processSpec "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +const ( + // Downstream timeout should be greater than upstream (in orchestrator proxy). + idleTimeout = 640 * time.Second + maxAge = 2 * time.Hour + + defaultPort = 49983 + + portScannerInterval = 1000 * time.Millisecond + + // This is the default user used in the container if not specified otherwise. + // It should be always overridden by the user in /init when building the template. + defaultUser = "root" + + kilobyte = 1024 + megabyte = 1024 * kilobyte +) + +var ( + Version = "0.5.4" + + commitSHA string + + isNotFC bool + port int64 + + versionFlag bool + commitFlag bool + startCmdFlag string + cgroupRoot string +) + +func parseFlags() { + flag.BoolVar( + &isNotFC, + "isnotfc", + false, + "isNotFCmode prints all logs to stdout", + ) + + flag.BoolVar( + &versionFlag, + "version", + false, + "print envd version", + ) + + flag.BoolVar( + &commitFlag, + "commit", + false, + "print envd source commit", + ) + + flag.Int64Var( + &port, + "port", + defaultPort, + "a port on which the daemon should run", + ) + + flag.StringVar( + &startCmdFlag, + "cmd", + "", + "a command to run on the daemon start", + ) + + flag.StringVar( + &cgroupRoot, + "cgroup-root", + "/sys/fs/cgroup", + "cgroup root directory", + ) + + flag.Parse() +} + +func withCORS(h http.Handler) http.Handler { + middleware := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{ + http.MethodHead, + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + }, + AllowedHeaders: []string{"*"}, + ExposedHeaders: append( + connectcors.ExposedHeaders(), + "Location", + "Cache-Control", + "X-Content-Type-Options", + ), + MaxAge: int(maxAge.Seconds()), + }) + + return middleware.Handler(h) +} + +func main() { + parseFlags() + + if versionFlag { + fmt.Printf("%s\n", Version) + + return + } + + if commitFlag { + fmt.Printf("%s\n", commitSHA) + + return + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := os.MkdirAll(host.WrennRunDir, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "error creating wrenn run directory: %v\n", err) + } + + defaults := &execcontext.Defaults{ + User: defaultUser, + EnvVars: utils.NewMap[string, string](), + } + isFCBoolStr := strconv.FormatBool(!isNotFC) + defaults.EnvVars.Store("WRENN_SANDBOX", isFCBoolStr) + if err := os.WriteFile(filepath.Join(host.WrennRunDir, ".WRENN_SANDBOX"), []byte(isFCBoolStr), 0o444); err != nil { + fmt.Fprintf(os.Stderr, "error writing sandbox file: %v\n", err) + } + + mmdsChan := make(chan *host.MMDSOpts, 1) + defer close(mmdsChan) + if !isNotFC { + go host.PollForMMDSOpts(ctx, mmdsChan, defaults.EnvVars) + } + + l := logs.NewLogger(ctx, isNotFC, mmdsChan) + + m := chi.NewRouter() + + envLogger := l.With().Str("logger", "envd").Logger() + fsLogger := l.With().Str("logger", "filesystem").Logger() + filesystemRpc.Handle(m, &fsLogger, defaults) + + cgroupManager := createCgroupManager() + defer func() { + err := cgroupManager.Close() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to close cgroup manager: %v\n", err) + } + }() + + processLogger := l.With().Str("logger", "process").Logger() + processService := processRpc.Handle(m, &processLogger, defaults, cgroupManager) + + service := api.New(&envLogger, defaults, mmdsChan, isNotFC) + handler := api.HandlerFromMux(service, m) + middleware := authn.NewMiddleware(permissions.AuthenticateUsername) + + s := &http.Server{ + Handler: withCORS( + service.WithAuthorization( + middleware.Wrap(handler), + ), + ), + Addr: fmt.Sprintf("0.0.0.0:%d", port), + // We remove the timeouts as the connection is terminated by closing of the sandbox and keepalive close. + ReadTimeout: 0, + WriteTimeout: 0, + IdleTimeout: idleTimeout, + } + + // TODO: Not used anymore in template build, replaced by direct envd command call. + if startCmdFlag != "" { + tag := "startCmd" + cwd := "/home/user" + user, err := permissions.GetUser("root") + if err != nil { + log.Fatalf("error getting user: %v", err) //nolint:gocritic // probably fine to bail if we're done? + } + + if err = processService.InitializeStartProcess(ctx, user, &processSpec.StartRequest{ + Tag: &tag, + Process: &processSpec.ProcessConfig{ + Envs: make(map[string]string), + Cmd: "/bin/bash", + Args: []string{"-l", "-c", startCmdFlag}, + Cwd: &cwd, + }, + }); err != nil { + log.Fatalf("error starting process: %v", err) + } + } + + // Bind all open ports on 127.0.0.1 and localhost to the eth0 interface + portScanner := publicport.NewScanner(portScannerInterval) + defer portScanner.Destroy() + + portLogger := l.With().Str("logger", "port-forwarder").Logger() + portForwarder := publicport.NewForwarder(&portLogger, portScanner, cgroupManager) + go portForwarder.StartForwarding(ctx) + + go portScanner.ScanAndBroadcast() + + err := s.ListenAndServe() + if err != nil { + log.Fatalf("error starting server: %v", err) + } +} + +func createCgroupManager() (m cgroups.Manager) { + defer func() { + if m == nil { + fmt.Fprintf(os.Stderr, "falling back to no-op cgroup manager\n") + m = cgroups.NewNoopManager() + } + }() + + metrics, err := host.GetMetrics() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to calculate host metrics: %v\n", err) + + return nil + } + + // try to keep 1/8 of the memory free, but no more than 128 MB + maxMemoryReserved := uint64(float64(metrics.MemTotal) * .125) + maxMemoryReserved = min(maxMemoryReserved, uint64(128)*megabyte) + + opts := []cgroups.Cgroup2ManagerOption{ + cgroups.WithCgroup2ProcessType(cgroups.ProcessTypePTY, "ptys", map[string]string{ + "cpu.weight": "200", // gets much preferred cpu access, to help keep these real time + }), + cgroups.WithCgroup2ProcessType(cgroups.ProcessTypeSocat, "socats", map[string]string{ + "cpu.weight": "150", // gets slightly preferred cpu access + "memory.min": fmt.Sprintf("%d", 5*megabyte), + "memory.low": fmt.Sprintf("%d", 8*megabyte), + }), + cgroups.WithCgroup2ProcessType(cgroups.ProcessTypeUser, "user", map[string]string{ + "memory.high": fmt.Sprintf("%d", metrics.MemTotal-maxMemoryReserved), + "cpu.weight": "50", // less than envd, and less than core processes that default to 100 + }), + } + if cgroupRoot != "" { + opts = append(opts, cgroups.WithCgroup2RootSysFSPath(cgroupRoot)) + } + + mgr, err := cgroups.NewCgroup2Manager(opts...) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create cgroup2 manager: %v\n", err) + + return nil + } + + return mgr +} diff --git a/envd/spec/buf.gen.yaml b/envd/spec/buf.gen.yaml new file mode 100644 index 0000000..c467274 --- /dev/null +++ b/envd/spec/buf.gen.yaml @@ -0,0 +1,14 @@ +version: v1 +plugins: + - plugin: go + out: ../internal/services/spec + opt: paths=source_relative + - plugin: connect-go + out: ../internal/services/spec + opt: paths=source_relative + +managed: + enabled: true + optimize_for: SPEED + go_package_prefix: + default: git.omukk.dev/wrenn/sandbox/envd/internal/services/spec diff --git a/envd/spec/envd.yaml b/envd/spec/envd.yaml new file mode 100644 index 0000000..83091f1 --- /dev/null +++ b/envd/spec/envd.yaml @@ -0,0 +1,303 @@ +openapi: 3.0.0 +info: + title: envd + version: 0.1.1 + description: API for managing files' content and controlling envd + +tags: + - name: files + +paths: + /health: + get: + summary: Check the health of the service + responses: + "204": + description: The service is healthy + + /metrics: + get: + summary: Get the stats of the service + security: + - AccessTokenAuth: [] + - {} + responses: + "200": + description: The resource usage metrics of the service + content: + application/json: + schema: + $ref: "#/components/schemas/Metrics" + + /init: + post: + summary: Set initial vars, ensure the time and metadata is synced with the host + security: + - AccessTokenAuth: [] + - {} + requestBody: + content: + application/json: + schema: + type: object + properties: + volumeMounts: + type: array + items: + $ref: "#/components/schemas/VolumeMount" + hyperloopIP: + type: string + description: IP address of the hyperloop server to connect to + envVars: + $ref: "#/components/schemas/EnvVars" + accessToken: + type: string + description: Access token for secure access to envd service + x-go-type: SecureToken + timestamp: + type: string + format: date-time + description: The current timestamp in RFC3339 format + defaultUser: + type: string + description: The default user to use for operations + defaultWorkdir: + type: string + description: The default working directory to use for operations + responses: + "204": + description: Env vars set, the time and metadata is synced with the host + + /envs: + get: + summary: Get the environment variables + security: + - AccessTokenAuth: [] + - {} + responses: + "200": + description: Environment variables + content: + application/json: + schema: + $ref: "#/components/schemas/EnvVars" + + /files: + get: + summary: Download a file + tags: [files] + security: + - AccessTokenAuth: [] + - {} + parameters: + - $ref: "#/components/parameters/FilePath" + - $ref: "#/components/parameters/User" + - $ref: "#/components/parameters/Signature" + - $ref: "#/components/parameters/SignatureExpiration" + responses: + "200": + $ref: "#/components/responses/DownloadSuccess" + "401": + $ref: "#/components/responses/InvalidUser" + "400": + $ref: "#/components/responses/InvalidPath" + "404": + $ref: "#/components/responses/FileNotFound" + "500": + $ref: "#/components/responses/InternalServerError" + post: + summary: Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. + tags: [files] + security: + - AccessTokenAuth: [] + - {} + parameters: + - $ref: "#/components/parameters/FilePath" + - $ref: "#/components/parameters/User" + - $ref: "#/components/parameters/Signature" + - $ref: "#/components/parameters/SignatureExpiration" + requestBody: + $ref: "#/components/requestBodies/File" + responses: + "200": + $ref: "#/components/responses/UploadSuccess" + "400": + $ref: "#/components/responses/InvalidPath" + "401": + $ref: "#/components/responses/InvalidUser" + "500": + $ref: "#/components/responses/InternalServerError" + "507": + $ref: "#/components/responses/NotEnoughDiskSpace" + +components: + securitySchemes: + AccessTokenAuth: + type: apiKey + in: header + name: X-Access-Token + + parameters: + FilePath: + name: path + in: query + required: false + description: Path to the file, URL encoded. Can be relative to user's home directory. + schema: + type: string + User: + name: username + in: query + required: false + description: User used for setting the owner, or resolving relative paths. + schema: + type: string + Signature: + name: signature + in: query + required: false + description: Signature used for file access permission verification. + schema: + type: string + SignatureExpiration: + name: signature_expiration + in: query + required: false + description: Signature expiration used for defining the expiration time of the signature. + schema: + type: integer + + requestBodies: + File: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + + responses: + UploadSuccess: + description: The file was uploaded successfully. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/EntryInfo" + + DownloadSuccess: + description: Entire file downloaded successfully. + content: + application/octet-stream: + schema: + type: string + format: binary + description: The file content + InvalidPath: + description: Invalid path + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + FileNotFound: + description: File not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + InvalidUser: + description: Invalid user + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + NotEnoughDiskSpace: + description: Not enough disk space + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + schemas: + Error: + required: + - message + - code + properties: + message: + type: string + description: Error message + code: + type: integer + description: Error code + EntryInfo: + required: + - path + - name + - type + properties: + path: + type: string + description: Path to the file + name: + type: string + description: Name of the file + type: + type: string + description: Type of the file + enum: + - file + EnvVars: + type: object + description: Environment variables to set + additionalProperties: + type: string + Metrics: + type: object + description: Resource usage metrics + properties: + ts: + type: integer + format: int64 + description: Unix timestamp in UTC for current sandbox time + cpu_count: + type: integer + description: Number of CPU cores + cpu_used_pct: + type: number + format: float + description: CPU usage percentage + mem_total: + type: integer + description: Total virtual memory in bytes + mem_used: + type: integer + description: Used virtual memory in bytes + disk_used: + type: integer + description: Used disk space in bytes + disk_total: + type: integer + description: Total disk space in bytes + VolumeMount: + type: object + description: Volume + additionalProperties: false + properties: + nfs_target: + type: string + path: + type: string + required: + - nfs_target + - path diff --git a/envd/spec/filesystem/filesystem.proto b/envd/spec/filesystem/filesystem.proto new file mode 100644 index 0000000..54bda3c --- /dev/null +++ b/envd/spec/filesystem/filesystem.proto @@ -0,0 +1,135 @@ +syntax = "proto3"; + +package filesystem; + +import "google/protobuf/timestamp.proto"; + +service Filesystem { + rpc Stat(StatRequest) returns (StatResponse); + rpc MakeDir(MakeDirRequest) returns (MakeDirResponse); + rpc Move(MoveRequest) returns (MoveResponse); + rpc ListDir(ListDirRequest) returns (ListDirResponse); + rpc Remove(RemoveRequest) returns (RemoveResponse); + + rpc WatchDir(WatchDirRequest) returns (stream WatchDirResponse); + + // Non-streaming versions of WatchDir + rpc CreateWatcher(CreateWatcherRequest) returns (CreateWatcherResponse); + rpc GetWatcherEvents(GetWatcherEventsRequest) returns (GetWatcherEventsResponse); + rpc RemoveWatcher(RemoveWatcherRequest) returns (RemoveWatcherResponse); +} + +message MoveRequest { + string source = 1; + string destination = 2; +} + +message MoveResponse { + EntryInfo entry = 1; +} + +message MakeDirRequest { + string path = 1; +} + +message MakeDirResponse { + EntryInfo entry = 1; +} + +message RemoveRequest { + string path = 1; +} + +message RemoveResponse {} + +message StatRequest { + string path = 1; +} + +message StatResponse { + EntryInfo entry = 1; +} + +message EntryInfo { + string name = 1; + FileType type = 2; + string path = 3; + int64 size = 4; + uint32 mode = 5; + string permissions = 6; + string owner = 7; + string group = 8; + google.protobuf.Timestamp modified_time = 9; + // If the entry is a symlink, this field contains the target of the symlink. + optional string symlink_target = 10; +} + +enum FileType { + FILE_TYPE_UNSPECIFIED = 0; + FILE_TYPE_FILE = 1; + FILE_TYPE_DIRECTORY = 2; + FILE_TYPE_SYMLINK = 3; +} + +message ListDirRequest { + string path = 1; + uint32 depth = 2; +} + +message ListDirResponse { + repeated EntryInfo entries = 1; +} + +message WatchDirRequest { + string path = 1; + bool recursive = 2; +} + +message FilesystemEvent { + string name = 1; + EventType type = 2; +} + +message WatchDirResponse { + oneof event { + StartEvent start = 1; + FilesystemEvent filesystem = 2; + KeepAlive keepalive = 3; + } + + message StartEvent {} + + message KeepAlive {} +} + +message CreateWatcherRequest { + string path = 1; + bool recursive = 2; +} + +message CreateWatcherResponse { + string watcher_id = 1; +} + +message GetWatcherEventsRequest { + string watcher_id = 1; +} + +message GetWatcherEventsResponse { + repeated FilesystemEvent events = 1; +} + +message RemoveWatcherRequest { + string watcher_id = 1; +} + +message RemoveWatcherResponse {} + +enum EventType { + EVENT_TYPE_UNSPECIFIED = 0; + EVENT_TYPE_CREATE = 1; + EVENT_TYPE_WRITE = 2; + EVENT_TYPE_REMOVE = 3; + EVENT_TYPE_RENAME = 4; + EVENT_TYPE_CHMOD = 5; +} diff --git a/envd/spec/generate.go b/envd/spec/generate.go new file mode 100644 index 0000000..0e9f11b --- /dev/null +++ b/envd/spec/generate.go @@ -0,0 +1,3 @@ +package spec + +//go:generate buf generate --template buf.gen.yaml diff --git a/envd/spec/process/process.proto b/envd/spec/process/process.proto new file mode 100644 index 0000000..99376a0 --- /dev/null +++ b/envd/spec/process/process.proto @@ -0,0 +1,171 @@ +syntax = "proto3"; + +package process; + +service Process { + rpc List(ListRequest) returns (ListResponse); + + rpc Connect(ConnectRequest) returns (stream ConnectResponse); + rpc Start(StartRequest) returns (stream StartResponse); + + rpc Update(UpdateRequest) returns (UpdateResponse); + + // Client input stream ensures ordering of messages + rpc StreamInput(stream StreamInputRequest) returns (StreamInputResponse); + rpc SendInput(SendInputRequest) returns (SendInputResponse); + rpc SendSignal(SendSignalRequest) returns (SendSignalResponse); + + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + rpc CloseStdin(CloseStdinRequest) returns (CloseStdinResponse); +} + +message PTY { + Size size = 1; + + message Size { + uint32 cols = 1; + uint32 rows = 2; + } +} + +message ProcessConfig { + string cmd = 1; + repeated string args = 2; + + map envs = 3; + optional string cwd = 4; +} + +message ListRequest {} + +message ProcessInfo { + ProcessConfig config = 1; + uint32 pid = 2; + optional string tag = 3; +} + +message ListResponse { + repeated ProcessInfo processes = 1; +} + +message StartRequest { + ProcessConfig process = 1; + optional PTY pty = 2; + optional string tag = 3; + // This is optional for backwards compatibility. + // We default to true. New SDK versions will set this to false by default. + optional bool stdin = 4; +} + +message UpdateRequest { + ProcessSelector process = 1; + + optional PTY pty = 2; +} + +message UpdateResponse {} + +message ProcessEvent { + oneof event { + StartEvent start = 1; + DataEvent data = 2; + EndEvent end = 3; + KeepAlive keepalive = 4; + } + + message StartEvent { + uint32 pid = 1; + } + + message DataEvent { + oneof output { + bytes stdout = 1; + bytes stderr = 2; + bytes pty = 3; + } + } + + message EndEvent { + sint32 exit_code = 1; + bool exited = 2; + string status = 3; + optional string error = 4; + } + + message KeepAlive {} +} + +message StartResponse { + ProcessEvent event = 1; +} + +message ConnectResponse { + ProcessEvent event = 1; +} + +message SendInputRequest { + ProcessSelector process = 1; + + ProcessInput input = 2; +} + +message SendInputResponse {} + +message ProcessInput { + oneof input { + bytes stdin = 1; + bytes pty = 2; + } +} + +message StreamInputRequest { + oneof event { + StartEvent start = 1; + DataEvent data = 2; + KeepAlive keepalive = 3; + } + + message StartEvent { + ProcessSelector process = 1; + } + + message DataEvent { + ProcessInput input = 2; + } + + message KeepAlive {} +} + +message StreamInputResponse {} + +enum Signal { + SIGNAL_UNSPECIFIED = 0; + SIGNAL_SIGTERM = 15; + SIGNAL_SIGKILL = 9; +} + +message SendSignalRequest { + ProcessSelector process = 1; + + Signal signal = 2; +} + +message SendSignalResponse {} + +message CloseStdinRequest { + ProcessSelector process = 1; +} + +message CloseStdinResponse {} + +message ConnectRequest { + ProcessSelector process = 1; +} + +message ProcessSelector { + oneof selector { + uint32 pid = 1; + string tag = 2; + } +} diff --git a/go.mod b/go.mod index a9ed4c4..2b27cbc 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,59 @@ -module github.com/wrenn-dev/wrenn-sandbox +module git.omukk.dev/wrenn/sandbox -go 1.23.0 +go 1.25.0 require ( - github.com/firecracker-microvm/firecracker-go-sdk v1.1.1 - github.com/go-chi/chi/v5 v5.2.1 - github.com/gorilla/websocket v1.5.3 - github.com/jackc/pgx/v5 v5.7.4 - github.com/mdlayher/vsock v1.2.1 - github.com/pressly/goose/v3 v3.24.3 - github.com/prometheus/client_golang v1.21.1 - github.com/rs/cors v1.11.1 - golang.org/x/crypto v0.36.0 - google.golang.org/grpc v1.71.0 - google.golang.org/protobuf v1.36.5 + connectrpc.com/connect v1.19.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect + github.com/containerd/fifo v1.0.0 // indirect + github.com/containernetworking/cni v1.0.1 // indirect + github.com/containernetworking/plugins v1.0.1 // indirect + github.com/firecracker-microvm/firecracker-go-sdk v1.0.0 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/go-openapi/analysis v0.21.2 // indirect + github.com/go-openapi/errors v0.20.2 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/loads v0.21.1 // indirect + github.com/go-openapi/runtime v0.24.0 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/strfmt v0.21.2 // indirect + github.com/go-openapi/swag v0.21.1 // indirect + github.com/go-openapi/validate v0.22.0 // indirect + github.com/go-stack/stack v1.8.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mdlayher/socket v0.4.1 // indirect + github.com/mdlayher/vsock v1.2.1 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pressly/goose/v3 v3.27.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5 // indirect + github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f // indirect + go.mongodb.org/mongo-driver v1.8.3 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b512f00 --- /dev/null +++ b/go.sum @@ -0,0 +1,1219 @@ +bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw= +github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg= +github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw= +github.com/Microsoft/go-winio v0.4.16-0.20201130162521-d1ffc52c7331/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= +github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= +github.com/Microsoft/go-winio v0.4.17-0.20210211115548-6eac466e5fa3/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/Microsoft/go-winio v0.4.17-0.20210324224401-5516f17a5958/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= +github.com/Microsoft/hcsshim v0.8.7-0.20190325164909-8abdbb8205e4/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= +github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ= +github.com/Microsoft/hcsshim v0.8.9/go.mod h1:5692vkUqntj1idxauYlpoINNKeqCiG6Sg38RRsjT5y8= +github.com/Microsoft/hcsshim v0.8.14/go.mod h1:NtVKoYxQuTLx6gEq0L96c9Ju4JbRJ4nY2ow3VK6a9Lg= +github.com/Microsoft/hcsshim v0.8.15/go.mod h1:x38A4YbHbdxJtc0sF6oIz+RG0npwSCAvn69iY6URG00= +github.com/Microsoft/hcsshim v0.8.16/go.mod h1:o5/SZqmR7x9JNKsW3pu+nqHm0MF8vbA+VxGOoXdC600= +github.com/Microsoft/hcsshim v0.8.20/go.mod h1:+w2gRZ5ReXQhFOrvSQeNfhrYB/dg3oDwTOcER2fw4I4= +github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5hlzMzRKMLyo42nCZ9oml8AdTlq/0cvIaBv6tK1RehU= +github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0= +github.com/alexflint/go-filemutex v1.1.0/go.mod h1:7P4iRhttt/nUvUOrYIhcpMzv2G6CY9UnI16Z+UJqRyk= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= +github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= +github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/buger/jsonparser v0.0.0-20180808090653-f4dd9f5a6b44/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= +github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmEg9bt0VpxxWqJlO4iwu3FBdHUzV7wQVg= +github.com/cilium/ebpf v0.0.0-20200702112145-1c8d4c9ef775/go.mod h1:7cR51M8ViRLIdUjrmSXlK9pkrsDlLHbO8jiB8X8JnOc= +github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX2Qs= +github.com/cilium/ebpf v0.4.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/containerd/aufs v0.0.0-20200908144142-dab0cbea06f4/go.mod h1:nukgQABAEopAHvB6j7cnP5zJ+/3aVcE7hCYqvIwAHyE= +github.com/containerd/aufs v0.0.0-20201003224125-76a6863f2989/go.mod h1:AkGGQs9NM2vtYHaUen+NljV0/baGCAPELGm2q9ZXpWU= +github.com/containerd/aufs v0.0.0-20210316121734-20793ff83c97/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= +github.com/containerd/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= +github.com/containerd/btrfs v0.0.0-20201111183144-404b9149801e/go.mod h1:jg2QkJcsabfHugurUvvPhS3E08Oxiuh5W/g1ybB4e0E= +github.com/containerd/btrfs v0.0.0-20210316141732-918d888fb676/go.mod h1:zMcX3qkXTAi9GI50+0HOeuV8LU2ryCE/V2vG/ZBiTss= +github.com/containerd/btrfs v1.0.0/go.mod h1:zMcX3qkXTAi9GI50+0HOeuV8LU2ryCE/V2vG/ZBiTss= +github.com/containerd/cgroups v0.0.0-20190717030353-c4b9ac5c7601/go.mod h1:X9rLEHIqSf/wfK8NsPqxJmeZgW4pcfzdXITDrUSJ6uI= +github.com/containerd/cgroups v0.0.0-20190919134610-bf292b21730f/go.mod h1:OApqhQ4XNSNC13gXIwDjhOQxjWa/NxkwZXJ1EvqT0ko= +github.com/containerd/cgroups v0.0.0-20200531161412-0dbf7f05ba59/go.mod h1:pA0z1pT8KYB3TCXK/ocprsh7MAkoW8bZVzPdih9snmM= +github.com/containerd/cgroups v0.0.0-20200710171044-318312a37340/go.mod h1:s5q4SojHctfxANBDvMeIaIovkq29IP48TKAxnhYRxvo= +github.com/containerd/cgroups v0.0.0-20200824123100-0b889c03f102/go.mod h1:s5q4SojHctfxANBDvMeIaIovkq29IP48TKAxnhYRxvo= +github.com/containerd/cgroups v0.0.0-20210114181951-8a68de567b68/go.mod h1:ZJeTFisyysqgcCdecO57Dj79RfL0LNeGiFUqLYQRYLE= +github.com/containerd/cgroups v1.0.1/go.mod h1:0SJrPIenamHDcZhEcJMNBB85rHcUsw4f25ZfBiPYRkU= +github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw= +github.com/containerd/console v0.0.0-20181022165439-0650fd9eeb50/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw= +github.com/containerd/console v0.0.0-20191206165004-02ecf6a7291e/go.mod h1:8Pf4gM6VEbTNRIT26AyyU7hxdQU3MvAvxVI0sc00XBE= +github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= +github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= +github.com/containerd/containerd v1.2.10/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.3.1-0.20191213020239-082f7e3aed57/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.4.0-beta.2.0.20200729163537-40b22ef07410/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.4.3/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.5.0-beta.1/go.mod h1:5HfvG1V2FsKesEGQ17k5/T7V960Tmcumvqn8Mc+pCYQ= +github.com/containerd/containerd v1.5.0-beta.3/go.mod h1:/wr9AVtEM7x9c+n0+stptlo/uBBoBORwEx6ardVcmKU= +github.com/containerd/containerd v1.5.0-beta.4/go.mod h1:GmdgZd2zA2GYIBZ0w09ZvgqEq8EfBp/m3lcVZIvPHhI= +github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoTJseu1FGOKuoA4nNb2s= +github.com/containerd/containerd v1.5.1/go.mod h1:0DOxVqwDy2iZvrZp2JUx/E+hS0UNTVn7dJnIOwtYR4g= +github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/containerd/continuity v0.0.0-20190815185530-f2a389ac0a02/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/containerd/continuity v0.0.0-20191127005431-f65d91d395eb/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/containerd/continuity v0.0.0-20200710164510-efbc4488d8fe/go.mod h1:cECdGN1O8G9bgKTlLhuPJimka6Xb/Gg7vYzCTNVxhvo= +github.com/containerd/continuity v0.0.0-20201208142359-180525291bb7/go.mod h1:kR3BEg7bDFaEddKm54WSmrol1fKWDU1nKYkgrcgZT7Y= +github.com/containerd/continuity v0.0.0-20210208174643-50096c924a4e/go.mod h1:EXlVlkqNba9rJe3j7w3Xa924itAMLgZH4UD/Q4PExuQ= +github.com/containerd/continuity v0.1.0/go.mod h1:ICJu0PwR54nI0yPEnJ6jcS+J7CZAUXrLh8lPo2knzsM= +github.com/containerd/fifo v0.0.0-20180307165137-3d5202aec260/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI= +github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI= +github.com/containerd/fifo v0.0.0-20191213151349-ff969a566b00/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0= +github.com/containerd/fifo v0.0.0-20200410184934-f15a3290365b/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0= +github.com/containerd/fifo v0.0.0-20201026212402-0724c46b320c/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0= +github.com/containerd/fifo v0.0.0-20210316144830-115abcc95a1d/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4= +github.com/containerd/fifo v1.0.0 h1:6PirWBr9/L7GDamKr+XM0IeUFXu5mf3M/BPpH9gaLBU= +github.com/containerd/fifo v1.0.0/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4= +github.com/containerd/go-cni v1.0.1/go.mod h1:+vUpYxKvAF72G9i1WoDOiPGRtQpqsNW/ZHtSlv++smU= +github.com/containerd/go-cni v1.0.2/go.mod h1:nrNABBHzu0ZwCug9Ije8hL2xBCYh/pjfMb1aZGrrohk= +github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0= +github.com/containerd/go-runc v0.0.0-20190911050354-e029b79d8cda/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0= +github.com/containerd/go-runc v0.0.0-20200220073739-7016d3ce2328/go.mod h1:PpyHrqVs8FTi9vpyHwPwiNEGaACDxT/N/pLcvMSRA9g= +github.com/containerd/go-runc v0.0.0-20201020171139-16b287bc67d0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok= +github.com/containerd/go-runc v1.0.0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok= +github.com/containerd/imgcrypt v1.0.1/go.mod h1:mdd8cEPW7TPgNG4FpuP3sGBiQ7Yi/zak9TYCG3juvb0= +github.com/containerd/imgcrypt v1.0.4-0.20210301171431-0ae5c75f59ba/go.mod h1:6TNsg0ctmizkrOgXRNQjAPFWpMYRWuiB6dSF4Pfa5SA= +github.com/containerd/imgcrypt v1.1.1-0.20210312161619-7ed62a527887/go.mod h1:5AZJNI6sLHJljKuI9IHnw1pWqo/F0nGDOuR9zgTs7ow= +github.com/containerd/imgcrypt v1.1.1/go.mod h1:xpLnwiQmEUJPvQoAapeb2SNCxz7Xr6PJrXQb0Dpc4ms= +github.com/containerd/nri v0.0.0-20201007170849-eb1350a75164/go.mod h1:+2wGSDGFYfE5+So4M5syatU0N0f0LbWpuqyMi4/BE8c= +github.com/containerd/nri v0.0.0-20210316161719-dbaa18c31c14/go.mod h1:lmxnXF6oMkbqs39FiCt1s0R2HSMhcLel9vNL3m4AaeY= +github.com/containerd/nri v0.1.0/go.mod h1:lmxnXF6oMkbqs39FiCt1s0R2HSMhcLel9vNL3m4AaeY= +github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o= +github.com/containerd/ttrpc v0.0.0-20190828172938-92c8520ef9f8/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o= +github.com/containerd/ttrpc v0.0.0-20191028202541-4f1b8fe65a5c/go.mod h1:LPm1u0xBw8r8NOKoOdNMeVHSawSsltak+Ihv+etqsE8= +github.com/containerd/ttrpc v1.0.1/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y= +github.com/containerd/ttrpc v1.0.2/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y= +github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc= +github.com/containerd/typeurl v0.0.0-20190911142611-5eb25027c9fd/go.mod h1:GeKYzf2pQcqv7tJ0AoCuuhtnqhva5LNU3U+OyKxxJpk= +github.com/containerd/typeurl v1.0.1/go.mod h1:TB1hUtrpaiO88KEK56ijojHS1+NeF0izUACaJW2mdXg= +github.com/containerd/typeurl v1.0.2/go.mod h1:9trJWW2sRlGub4wZJRTW83VtbOLS6hwcDZXTn6oPz9s= +github.com/containerd/zfs v0.0.0-20200918131355-0a33824f23a2/go.mod h1:8IgZOBdv8fAgXddBT4dBXJPtxyRsejFIpXoklgxgEjw= +github.com/containerd/zfs v0.0.0-20210301145711-11e8f1707f62/go.mod h1:A9zfAbMlQwE+/is6hi0Xw8ktpL+6glmqZYtevJgaB8Y= +github.com/containerd/zfs v0.0.0-20210315114300-dde8f0fda960/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY= +github.com/containerd/zfs v0.0.0-20210324211415-d5c4544f0433/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY= +github.com/containerd/zfs v1.0.0/go.mod h1:m+m51S1DvAP6r3FcmYCp54bQ34pyOwTieQDNRIRHsFY= +github.com/containernetworking/cni v0.7.1/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY= +github.com/containernetworking/cni v0.8.0/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY= +github.com/containernetworking/cni v0.8.1/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY= +github.com/containernetworking/cni v1.0.1 h1:9OIL/sZmMYDBe+G8svzILAlulUpaDTUjeAbtH/JNLBo= +github.com/containernetworking/cni v1.0.1/go.mod h1:AKuhXbN5EzmD4yTNtfSsX3tPcmtrBI6QcRV0NiNt15Y= +github.com/containernetworking/plugins v0.8.6/go.mod h1:qnw5mN19D8fIwkqW7oHHYDHVlzhJpcY6TQxn/fUyDDM= +github.com/containernetworking/plugins v0.8.7/go.mod h1:R7lXeZaBzpfqapcAbHRW8/CYwm0dHzbz0XEjofx0uB0= +github.com/containernetworking/plugins v0.9.1 h1:FD1tADPls2EEi3flPc2OegIY1M9pUa9r2Quag7HMLV8= +github.com/containernetworking/plugins v0.9.1/go.mod h1:xP/idU2ldlzN6m4p5LmGiwRDjeJr6FLK6vuiUwoH7P8= +github.com/containernetworking/plugins v1.0.1 h1:wwCfYbTCj5FC0EJgyzyjTXmqysOiJE9r712Z+2KVZAk= +github.com/containernetworking/plugins v1.0.1/go.mod h1:QHCfGpaTwYTbbH+nZXKVTxNBDZcxSOplJT5ico8/FLE= +github.com/containers/ocicrypt v1.0.1/go.mod h1:MeJDzk1RJHv89LjsH0Sp5KTY3ZYkjXO/C+bKAeWFIrc= +github.com/containers/ocicrypt v1.1.0/go.mod h1:b8AOe0YR67uU8OqfVNcznfFpAzu3rdgUV4GP9qXPfu4= +github.com/containers/ocicrypt v1.1.1/go.mod h1:Dm55fwWm1YZAjYRaJ94z2mfZikIyIN4B0oB3dj3jFxY= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-iptables v0.4.5/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU= +github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU= +github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= +github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= +github.com/d2g/dhcp4 v0.0.0-20170904100407-a1d1b6c41b1c/go.mod h1:Ct2BUK8SB0YC1SMSibvLzxjeJLnrYEVLULFNiHY9YfQ= +github.com/d2g/dhcp4client v1.0.0/go.mod h1:j0hNfjhrt2SxUOw55nL0ATM/z4Yt3t2Kd1mW34z5W5s= +github.com/d2g/dhcp4server v0.0.0-20181031114812-7d4a0a7f59a5/go.mod h1:Eo87+Kg/IX2hfWJfwxMzLyuSZyxSoAug2nGa1G2QAi8= +github.com/d2g/hardwareaddr v0.0.0-20190221164911-e7d9fbe030e4/go.mod h1:bMl4RjIciD2oAxI7DmWRx6gbeqrkoLqv3MV0vzNad+I= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= +github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= +github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY= +github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/go-events v0.0.0-20170721190031-9461782956ad/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/firecracker-microvm/firecracker-go-sdk v0.22.0 h1:hk28AO5ArAX9iHomi6axNLK+6+8gz1wi3ooNsUTlSFQ= +github.com/firecracker-microvm/firecracker-go-sdk v0.22.0/go.mod h1:lr7w/zmzIi72h+dDMQsRmmKS63EKvnFPEpg2KrjX2X0= +github.com/firecracker-microvm/firecracker-go-sdk v1.0.0 h1:HTnxnX9pvQkQOHjv+TppzUyi2BNFL/7aegSlqIK/usY= +github.com/firecracker-microvm/firecracker-go-sdk v1.0.0/go.mod h1:iXd7gqdwzvhB4VbNVMb70g/IY04fOuQbbBGM+PQEkgo= +github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= +github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA= +github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= +github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.18.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.19.2/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= +github.com/go-openapi/analysis v0.19.4/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= +github.com/go-openapi/analysis v0.19.5/go.mod h1:hkEAkxagaIvIP7VTn8ygJNkd4kAYON2rCu0v0ObL0AU= +github.com/go-openapi/analysis v0.19.10/go.mod h1:qmhS3VNFxBlquFJ0RGoDtylO9y4pgTAUNE9AEEMdlJQ= +github.com/go-openapi/analysis v0.21.2 h1:hXFrOYFHUAMQdu6zwAiKKJHJQ8kqZs1ux/ru1P1wLJU= +github.com/go-openapi/analysis v0.21.2/go.mod h1:HZwRk4RRisyG8vx2Oe6aqeSQcoxRp47Xkp3+K6q+LdY= +github.com/go-openapi/errors v0.17.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.18.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-openapi/errors v0.19.3/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-openapi/errors v0.19.6/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= +github.com/go-openapi/errors v0.19.7/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= +github.com/go-openapi/errors v0.19.8/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= +github.com/go-openapi/errors v0.19.9/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= +github.com/go-openapi/errors v0.20.2 h1:dxy7PGTqEh94zj2E3h1cUmQQWiM1+aeCROfAr02EmK8= +github.com/go-openapi/errors v0.20.2/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= +github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.18.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= +github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/loads v0.17.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.18.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.2/go.mod h1:QAskZPMX5V0C2gvfkGZzJlINuP7Hx/4+ix5jWFxsNPs= +github.com/go-openapi/loads v0.19.3/go.mod h1:YVfqhUCdahYwR3f3iiwQLhicVRvLlU/WO5WPaZvcvSI= +github.com/go-openapi/loads v0.19.5/go.mod h1:dswLCAdonkRufe/gSUC3gN8nTSaB9uaS2es0x5/IbjY= +github.com/go-openapi/loads v0.21.1 h1:Wb3nVZpdEzDTcly8S4HMkey6fjARRzb7iEaySimlDW0= +github.com/go-openapi/loads v0.21.1/go.mod h1:/DtAMXXneXFjbQMGEtbamCZb+4x7eGwkvZCvBmwUG+g= +github.com/go-openapi/runtime v0.0.0-20180920151709-4f900dc2ade9/go.mod h1:6v9a6LTXWQCdL8k1AO3cvqx5OtZY/Y9wKTgaoP6YRfA= +github.com/go-openapi/runtime v0.19.0/go.mod h1:OwNfisksmmaZse4+gpV3Ne9AyMOlP1lt4sK4FXt0O64= +github.com/go-openapi/runtime v0.19.4/go.mod h1:X277bwSUBxVlCYR3r7xgZZGKVvBd/29gLDlFGtJ8NL4= +github.com/go-openapi/runtime v0.19.15/go.mod h1:dhGWCTKRXlAfGnQG0ONViOZpjfg0m2gUt9nTQPQZuoo= +github.com/go-openapi/runtime v0.19.16/go.mod h1:5P9104EJgYcizotuXhEuUrzVc+j1RiSjahULvYmlv98= +github.com/go-openapi/runtime v0.19.22/go.mod h1:Lm9YGCeecBnUUkFTxPC4s1+lwrkJ0pthx8YvyjCfkgk= +github.com/go-openapi/runtime v0.24.0 h1:vTgDijpGLCgJOJTdAp5kG+O+nRsVCbH417YQ3O0iZo0= +github.com/go-openapi/runtime v0.24.0/go.mod h1:AKurw9fNre+h3ELZfk6ILsfvPN+bvvlaU/M9q/r9hpk= +github.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.18.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY= +github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= +github.com/go-openapi/spec v0.19.6/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= +github.com/go-openapi/spec v0.19.8/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/strfmt v0.17.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.18.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.19.0/go.mod h1:+uW+93UVvGGq2qGaZxdDeJqSAqBqBdl+ZPMF/cC8nDY= +github.com/go-openapi/strfmt v0.19.2/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU= +github.com/go-openapi/strfmt v0.19.3/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU= +github.com/go-openapi/strfmt v0.19.4/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk= +github.com/go-openapi/strfmt v0.19.5/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk= +github.com/go-openapi/strfmt v0.21.0/go.mod h1:ZRQ409bWMj+SOgXofQAGTIo2Ebu72Gs+WaRADcS5iNg= +github.com/go-openapi/strfmt v0.21.1/go.mod h1:I/XVKeLc5+MM5oPNN7P6urMOpuLXEcNrCX/rPGuWb0k= +github.com/go-openapi/strfmt v0.21.2 h1:5NDNgadiX1Vhemth/TH4gCGopWSTdDjxl60H3B7f+os= +github.com/go-openapi/strfmt v0.21.2/go.mod h1:I/XVKeLc5+MM5oPNN7P6urMOpuLXEcNrCX/rPGuWb0k= +github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.18.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.7/go.mod h1:ao+8BpOPyKdpQz3AOJfbeEVpLmWAvlT1IfTe5McPyhY= +github.com/go-openapi/swag v0.19.9/go.mod h1:ao+8BpOPyKdpQz3AOJfbeEVpLmWAvlT1IfTe5McPyhY= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU= +github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= +github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA= +github.com/go-openapi/validate v0.19.3/go.mod h1:90Vh6jjkTn+OT1Eefm0ZixWNFjhtOH7vS9k0lo6zwJo= +github.com/go-openapi/validate v0.19.10/go.mod h1:RKEZTUWDkxKQxN2jDT7ZnZi2bhZlbNMAuKvKB+IaGx8= +github.com/go-openapi/validate v0.19.11/go.mod h1:Rzou8hA/CBw8donlS6WNEUQupNvUZ0waH08tGe6kAQ4= +github.com/go-openapi/validate v0.21.0/go.mod h1:rjnrwK57VJ7A8xqfpAOEKRH8yQSGUriMu5/zuPSQ1hg= +github.com/go-openapi/validate v0.22.0 h1:b0QecH6VslW/TxtpKgzpO1SNG7GU2FsaqKdP1E2T50Y= +github.com/go-openapi/validate v0.22.0/go.mod h1:rjnrwK57VJ7A8xqfpAOEKRH8yQSGUriMu5/zuPSQ1hg= +github.com/go-ping/ping v0.0.0-20211130115550-779d1e919534/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= +github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= +github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= +github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= +github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs= +github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= +github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28= +github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo= +github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk= +github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw= +github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= +github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg= +github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE= +github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8= +github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= +github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= +github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= +github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= +github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/googleapis v1.2.0/go.mod h1:Njal3psf3qN6dwBtQfUmBZh2ybovJ0tlu3o/AC7HYjU= +github.com/gogo/googleapis v1.4.0/go.mod h1:5YRNX2z1oM5gXdAkurHa942MDgEJyk02w4OecKY87+c= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA= +github.com/j-keck/arping v1.0.2/go.mod h1:aJbELhR92bSk7tp79AWM/ftfc90EfEi2bQJrbBFOsPw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= +github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mailru/easyjson v0.7.1/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= +github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mdlayher/socket v0.2.0/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E= +github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= +github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= +github.com/mdlayher/vsock v1.1.1/go.mod h1:Y43jzcy7KM3QB+/FK15pfqGxDMCMzUXWegEfIbSM18U= +github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= +github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= +github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= +github.com/moby/sys/mountinfo v0.4.1/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= +github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGqsZeMYowQ= +github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/onsi/ginkgo v0.0.0-20151202141238-7f8ab55aaf3b/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v0.0.0-20151007035656-2152b45fa28a/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= +github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0= +github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/go-digest v1.0.0-rc1.0.20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/opencontainers/runc v1.0.0-rc8.0.20190926000215-3e425f80a8c9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/opencontainers/runc v1.0.0-rc9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/opencontainers/runc v1.0.0-rc93/go.mod h1:3NOsor4w32B2tC0Zbl8Knk4Wg84SM2ImC1fxBuqJ/H0= +github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.0.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.0.2-0.20190207185410-29686dbc5559/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.0.3-0.20200929063507-e6143ca7d51d/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs= +github.com/opencontainers/selinux v1.6.0/go.mod h1:VVGKuOLlE7v4PJyT6h7mNWvq1rzqiriPsEqVhc+svHE= +github.com/opencontainers/selinux v1.8.0/go.mod h1:RScLhm78qiWa2gbVCcGkC7tCGdgk3ogry1nUQF8Evvo= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= +github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= +github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= +github.com/pressly/goose/v3 v3.15.1 h1:dKaJ1SdLvS/+HtS8PzFT0KBEtICC1jewLXM+b3emlv8= +github.com/pressly/goose/v3 v3.15.1/go.mod h1:0E3Yg/+EwYzO6Rz2P98MlClFgIcoujbVRs575yi3iIM= +github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= +github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= +github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.0-20190522114515-bc1a522cf7b1/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= +github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= +github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/sparrc/go-ping v0.0.0-20190613174326-4e5b6552494c/go.mod h1:eMyUVp6f/5jnzM+3zahzl7q6UXLbgSc3MKg/+ow9QW0= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8= +github.com/stretchr/objx v0.0.0-20180129172003-8a3f7159479f/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/tchap/go-patricia v2.2.6+incompatible/go.mod h1:bmLyhP68RS6kStMGxByiQ23RP/odRBOTVjwp2cDyi6I= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= +github.com/vishvananda/netlink v0.0.0-20181108222139-023a6dafdcdf/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk= +github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= +github.com/vishvananda/netlink v1.1.1-0.20201029203352-d40f9887b852/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= +github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5 h1:+UB2BJA852UkGH42H+Oee69djmxS3ANzl2b/JtT1YiA= +github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= +github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc/go.mod h1:ZjcWmFBXmLKZu9Nxj3WKYEafiSqer2rnvPr0en9UNpI= +github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= +github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f h1:p4VB7kIXpOQvVn1ZaTIVp+3vuYAXFe3OJEvjbUYJLaA= +github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= +github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= +github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= +github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= +github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= +github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.etcd.io/etcd v0.5.0-alpha.5.0.20200910180754-dd1b699fc489/go.mod h1:yVHk9ub3CSBatqGNg7GRmsnfLWtoW60w4eDYfh7vHDg= +go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.3.0/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= +go.mongodb.org/mongo-driver v1.3.4/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= +go.mongodb.org/mongo-driver v1.7.3/go.mod h1:NqaYOwnXWr5Pm7AOpO5QFxKJ503nbMse/R79oO62zWg= +go.mongodb.org/mongo-driver v1.7.5/go.mod h1:VXEWRZ6URJIkUq2SCAyapmhH0ZLRBP+FT4xhp5Zvxng= +go.mongodb.org/mongo-driver v1.8.3 h1:TDKlTkGDKm9kkJVUOAXDK5/fkqKHJVwYQSpoRfB43R4= +go.mongodb.org/mongo-driver v1.8.3/go.mod h1:0sQWfOeY63QTntERDJJ/0SuKK0T1uVSgKCuAROlKEPY= +go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181009213950-7c1a557ab941/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190320064053-1272bf9dcd53/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190522044717-8097e1b27ff5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190812073006-9eafafc0a87e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200120151820-655fe14d7479/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200817155316-9781c653f443/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200922070232-aee5d888a860/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201117170446-d9b008d0a637/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190617190820-da514acc4774/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190522204451-c2c4e71fbf69/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200117163144-32f20d992d24/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a h1:pOwg4OoaRYScjmR4LlLgdtnyoHYTSAVhhqe5uPdpII8= +google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +k8s.io/api v0.20.1/go.mod h1:KqwcCVogGxQY3nBlRpwt+wpAMF/KjaCc7RpywacvqUo= +k8s.io/api v0.20.4/go.mod h1:++lNL1AJMkDymriNniQsWRkMDzRaX2Y/POTUi8yvqYQ= +k8s.io/api v0.20.6/go.mod h1:X9e8Qag6JV/bL5G6bU8sdVRltWKmdHsFUGS3eVndqE8= +k8s.io/apimachinery v0.20.1/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU= +k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU= +k8s.io/apimachinery v0.20.6/go.mod h1:ejZXtW1Ra6V1O5H8xPBGz+T3+4gfkTCeExAHKU57MAc= +k8s.io/apiserver v0.20.1/go.mod h1:ro5QHeQkgMS7ZGpvf4tSMx6bBOgPfE+f52KwvXfScaU= +k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM= +k8s.io/apiserver v0.20.6/go.mod h1:QIJXNt6i6JB+0YQRNcS0hdRHJlMhflFmsBDeSgT1r8Q= +k8s.io/client-go v0.20.1/go.mod h1:/zcHdt1TeWSd5HoUe6elJmHSQ6uLLgp4bIJHVEuy+/Y= +k8s.io/client-go v0.20.4/go.mod h1:LiMv25ND1gLUdBeYxBIwKpkSC5IsozMMmOOeSJboP+k= +k8s.io/client-go v0.20.6/go.mod h1:nNQMnOvEUEsOzRRFIIkdmYOjAZrC8bgq0ExboWSU1I0= +k8s.io/component-base v0.20.1/go.mod h1:guxkoJnNoh8LNrbtiQOlyp2Y2XFCZQmrcg2n/DeYNLk= +k8s.io/component-base v0.20.4/go.mod h1:t4p9EdiagbVCJKrQ1RsA5/V4rFQNDfRlevJajlGwgjI= +k8s.io/component-base v0.20.6/go.mod h1:6f1MPBAeI+mvuts3sIdtpjljHWBQ2cIy38oBIWMYnrM= +k8s.io/cri-api v0.17.3/go.mod h1:X1sbHmuXhwaHs9xxYffLqJogVsnI+f6cPRcgPel7ywM= +k8s.io/cri-api v0.20.1/go.mod h1:2JRbKt+BFLTjtrILYVqQK5jqhI+XNdF6UiGMgczeBCI= +k8s.io/cri-api v0.20.4/go.mod h1:2JRbKt+BFLTjtrILYVqQK5jqhI+XNdF6UiGMgczeBCI= +k8s.io/cri-api v0.20.6/go.mod h1:ew44AjNXwyn1s0U4xCKGodU7J1HzBeZ1MpGrpa5r8Yc= +k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= +k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM= +k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= +k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.14/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.15/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg= +sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/structured-merge-diff/v4 v4.0.3/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=