Skip to content
Snippets Groups Projects
Commit 5a251df9bb98 authored by Christophe de Vienne's avatar Christophe de Vienne
Browse files

cmd: Add 'version' command & UI options

parent 18f8a09aa903
No related branches found
No related tags found
No related merge requests found
Pipeline #113831 failed
......@@ -38,6 +38,7 @@
}
type Program struct {
Version Version
BootstrapParser *flags.Parser
Parser *flags.Parser
......@@ -58,7 +59,9 @@
hasDB bool
dbMigrateSource source.Driver
middlewares []Middleware
setupXbusActors func(*Program) []XbusActorFactory
xbusActorNames []string
setupHandler func(*Program) http.Handler
setupSubcommands []subcommand
......@@ -61,7 +64,9 @@
setupXbusActors func(*Program) []XbusActorFactory
xbusActorNames []string
setupHandler func(*Program) http.Handler
setupSubcommands []subcommand
postInit []func(*Program)
}
type Option func(program *Program)
......@@ -72,6 +77,38 @@
}
}
type Middleware interface {
Middleware(http.Handler) (http.Handler, error)
}
type middlewareFunc func(http.Handler) (http.Handler, error)
func (f middlewareFunc) Middleware(next http.Handler) (http.Handler, error) {
return f(next)
}
type middlewareFuncNoErr func(http.Handler) http.Handler
func (f middlewareFuncNoErr) Middleware(next http.Handler) (http.Handler, error) {
return f(next), nil
}
func WithMiddleware(middleware any) Option {
var m Middleware
switch f := middleware.(type) {
case func(http.Handler) http.Handler:
m = middlewareFuncNoErr(f)
case func(http.Handler) (http.Handler, error):
m = middlewareFunc(f)
default:
panic(fmt.Errorf("Invalid middleware type: %t", middleware))
}
return func(program *Program) {
program.middlewares = append(program.middlewares, m)
}
}
func WithXbusActors(factories func(*Program) []XbusActorFactory) Option {
return func(program *Program) {
program.setupXbusActors = factories
......@@ -103,8 +140,14 @@
}
}
func NewProgram(name string, options ...Option) *Program {
type Version struct {
Version string
Hash string
Build string
}
func NewProgram(name string, version Version, options ...Option) *Program {
bootstrapParser := flags.NewNamedParser(name, flags.IgnoreUnknown)
parser := flags.NewNamedParser(name, flags.HelpFlag|flags.PassDoubleDash)
program := Program{
......@@ -107,7 +150,8 @@
bootstrapParser := flags.NewNamedParser(name, flags.IgnoreUnknown)
parser := flags.NewNamedParser(name, flags.HelpFlag|flags.PassDoubleDash)
program := Program{
Version: version,
BootstrapParser: bootstrapParser,
Parser: parser,
Logger: orusapi.DefaultLogger(os.Stdout),
......@@ -148,6 +192,7 @@
program.ServeCmd = SetupServeCmd(&program)
program.GenerateConfigCmd = SetupGenerateConfigCmd(&program)
_ = SetupVersionCmd(&program)
if program.hasDB {
g, err := parser.AddGroup("Database", "Database options", &program.DatabaseOptions)
......@@ -166,6 +211,10 @@
}
}
for _, pi := range program.postInit {
pi(&program)
}
return &program
}
......@@ -169,6 +218,12 @@
return &program
}
func PostInit(pi func(program *Program)) func(program *Program) {
return func(program *Program) {
program.postInit = append(program.postInit, pi)
}
}
func (program *Program) ParseArgs(args []string) int {
if _, err := program.BootstrapParser.ParseArgs(args); err != nil {
program.Logger.Err(err).Msg("could not parse command line")
......
package cmd
import (
"io/fs"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/timewasted/go-accept-headers"
"orus.io/orus-io/go-orusapi"
)
type UIOptions struct {
fs fs.FS
paths []string
External string `long:"ui-external" ini-name:"ui-ui-external" description:"UI external server"`
}
func NewIgnoreNotFoundResponseWriter(rw http.ResponseWriter) *IgnoreNotFoundResponseWriter {
return &IgnoreNotFoundResponseWriter{
header: rw.Header().Clone(),
}
}
type IgnoreNotFoundResponseWriter struct {
header http.Header
notfound *bool
next http.ResponseWriter
}
func (rw *IgnoreNotFoundResponseWriter) NotFound() bool {
return rw.notfound != nil && *rw.notfound
}
func (rw *IgnoreNotFoundResponseWriter) Header() http.Header {
return rw.header
}
func (rw *IgnoreNotFoundResponseWriter) flushHeader() {
nh := rw.next.Header()
for k := range nh {
if _, ok := rw.header[k]; !ok {
nh.Del(k)
}
}
for k, v := range rw.header {
nh[k] = v
}
}
func (rw *IgnoreNotFoundResponseWriter) WriteHeader(statusCode int) {
notFound := statusCode == http.StatusNotFound
rw.notfound = &notFound
if !notFound {
rw.flushHeader()
rw.next.WriteHeader(statusCode)
}
}
func (rw *IgnoreNotFoundResponseWriter) Write(data []byte) (int, error) {
if rw.notfound == nil {
var value bool
rw.notfound = &value
rw.flushHeader()
}
if *rw.notfound {
return 0, nil
}
return rw.next.Write(data)
}
func WithUI(uifs fs.FS, prefix string) Option {
return PostInit(func(program *Program) {
uiOptions := UIOptions{
fs: uifs,
}
var serveFound bool
for _, cmd := range program.Parser.Commands() {
if cmd.Name == "serve" {
_, err := cmd.AddGroup("ui", "User Interface", &uiOptions)
if err != nil {
panic(err)
}
serveFound = true
break
}
}
if !serveFound {
panic("serve command not found")
}
middleware := func(next http.Handler) (http.Handler, error) {
var uiHandler http.Handler
if uiOptions.External == "" {
uiHandler = orusapi.NewSPAFileServer(
http.FS(uiOptions.fs),
prefix,
)
} else {
u, err := url.Parse(uiOptions.External)
if err != nil {
return nil, err
}
uiHandler = httputil.NewSingleHostReverseProxy(u)
}
if prefix != "" {
uiHandler = http.StripPrefix(prefix, uiHandler)
}
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if prefix != "" {
if !strings.HasPrefix(r.URL.Path, prefix) {
next.ServeHTTP(rw, r)
}
}
accepted, err := accept.Negotiate(
r.Header.Get("Accept"),
"text/html", "application/json")
if err != nil || accepted == "application/json" {
next.ServeHTTP(rw, r)
} else {
rwWrapper := NewIgnoreNotFoundResponseWriter(rw)
uiHandler.ServeHTTP(rwWrapper, r)
if rwWrapper.NotFound() {
next.ServeHTTP(rw, r)
}
}
}), nil
}
WithMiddleware(middleware)(program)
})
}
package cmd
import (
"fmt"
)
func SetupVersionCmd(program *Program) *VersionCmd {
cmd := VersionCmd{
program: program,
}
if _, err := program.Parser.AddCommand(
"version", "Show the program version", "", &cmd,
); err != nil {
program.Logger.Fatal().Msg(err.Error())
}
return &cmd
}
type VersionCmd struct {
program *Program
Verbose bool `short:"v" long:"verbose" description:"display the source hash and build number if available"`
}
func (cmd *VersionCmd) Execute([]string) error {
versionLine := cmd.program.Version.Version
if cmd.Verbose {
if cmd.program.Version.Hash != "" {
versionLine += "\nHash:" + cmd.program.Version.Hash
}
if cmd.program.Version.Build != "" {
versionLine += "\nBuild:" + cmd.program.Version.Build
}
}
if _, err := fmt.Println(versionLine); err != nil {
return err
}
return nil
}
......@@ -66,6 +66,7 @@
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.16.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/sys v0.21.0 // indirect
......
package orusapi
import (
"mime"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
func NewSPAFileServer(root http.FileSystem, version string) http.Handler {
return &SPAFileHandler{root, version}
}
type SPAFileHandler struct {
root http.FileSystem
etag string
}
var encodingWeight = map[string]int{
"deflate": 1,
"gzip": 2,
"br": 3,
}
func acceptEncoding(r *http.Request) []string {
s := r.Header.Get("Accept-Encoding")
splitted := strings.Split(s, ",")
for i := range splitted {
splitted[i] = strings.Trim(splitted[i], " ")
}
sort.Slice(splitted, func(i, j int) bool {
return encodingWeight[splitted[i]] > encodingWeight[splitted[j]]
})
return splitted
}
func encoding(encoding string) (string, string) {
switch encoding {
case "br":
return "br", ".br"
case "gzip":
return "gzip", ".gz"
case "deflate":
return "", ""
default:
return "", ""
}
}
func (h *SPAFileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
upath := r.URL.Path
if !strings.HasPrefix(upath, "/") {
upath = "/" + upath
r.URL.Path = upath
}
_, debugMode := r.URL.Query()["debug"]
h.serveFile(w, r, path.Clean(upath), true, debugMode)
}
// localRedirect gives a Moved Permanently response.
// It does not convert relative paths to absolute paths like Redirect does.
func (h *SPAFileHandler) localRedirect(w http.ResponseWriter, r *http.Request, newPath string) {
if q := r.URL.RawQuery; q != "" {
newPath += "?" + q
}
w.Header().Set("Location", newPath)
w.WriteHeader(http.StatusMovedPermanently)
}
func (h *SPAFileHandler) open(name string, acceptEncoding []string) (http.File, string, error) {
for _, enc := range acceptEncoding {
contentEncoding, ext := encoding(enc)
f, err := h.root.Open(name + ext)
if err == nil {
return f, contentEncoding, nil
}
if !os.IsNotExist(err) {
return nil, "", err
}
}
return nil, "", os.ErrNotExist
}
func (h *SPAFileHandler) serveFile(w http.ResponseWriter, r *http.Request, name string, redirect bool, debugMode bool) {
ifNoneMatch := r.Header.Get("If-None-Match")
if ifNoneMatch == h.etag {
w.WriteHeader(http.StatusNotModified)
return
}
indexPage := "/index.html"
if debugMode {
indexPage = "/index.debug.html"
}
if strings.HasPrefix(r.URL.Path, "/payment/") {
indexPage = "/payment.html"
if debugMode {
indexPage = "/payment.debug.html"
}
}
// redirect .../index.html to .../
// can't use Redirect() because that would make the path absolute,
// which would be a problem running under StripPrefix
if strings.HasSuffix(r.URL.Path, indexPage) {
h.localRedirect(w, r, "./")
return
}
f, contentEncoding, err := h.open(name, acceptEncoding(r))
if os.IsNotExist(err) {
f, contentEncoding, err = h.open(indexPage, acceptEncoding(r))
if err != nil {
name = indexPage
}
}
if err != nil {
msg, code := toHTTPError(err)
http.Error(w, msg, code)
return
}
defer f.Close()
d, err := f.Stat()
if err != nil {
msg, code := toHTTPError(err)
http.Error(w, msg, code)
return
}
if redirect {
// redirect to canonical path: / at end of directory url
// r.URL.Path always begins with /
url := r.URL.Path
if d.IsDir() {
if url[len(url)-1] != '/' {
h.localRedirect(w, r, path.Base(url)+"/")
return
}
} else {
if url[len(url)-1] == '/' {
h.localRedirect(w, r, "../"+path.Base(url))
return
}
}
}
if d.IsDir() {
url := r.URL.Path
// redirect if the directory name doesn't end in a slash
if url == "" || url[len(url)-1] != '/' {
h.localRedirect(w, r, path.Base(url)+"/")
return
}
// use contents of index.html for directory, if present
index := strings.TrimSuffix(name, "/") + indexPage
ff, ffContentEncoding, err := h.open(index, acceptEncoding(r))
if err == nil {
defer ff.Close()
dd, err := ff.Stat()
if err == nil {
name = index
d = dd
f = ff
contentEncoding = ffContentEncoding
}
}
}
// Still a directory? (we didn't find an index.html file)
if d.IsDir() {
http.Error(w, "Not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(name)))
if contentEncoding != "" {
w.Header().Set("Content-Encoding", contentEncoding)
}
w.Header().Set("ETag", h.etag)
w.Header().Set("Cache-Control", "no-cache")
http.ServeContent(w, r, d.Name(), d.ModTime(), f)
}
// The following code is copied from the golang 'http' package
func toHTTPError(err error) (msg string, httpStatus int) {
if os.IsNotExist(err) {
return "404 page not found", http.StatusNotFound
}
if os.IsPermission(err) {
return "403 Forbidden", http.StatusForbidden
}
// Default:
return "500 Internal Server Error", http.StatusInternalServerError
}
package main
import (
"fmt"
"os"
"os/exec"
"strings"
"text/template"
)
func syscmd(name string, arg ...string) string {
cmd := exec.Command(name, arg...)
out, err := cmd.CombinedOutput()
if err != nil {
fmt.Println(string(out))
panic(err)
}
splitted := strings.Split(string(out), "\n")
lines := make([]string, 0, len(splitted))
for _, l := range splitted {
l = strings.TrimSpace(l)
if l == "" {
continue
}
lines = append(lines, l)
}
if len(lines) != 1 {
fmt.Println(string(out))
panic("Expects a single line")
}
return lines[0]
}
func getVersionTag(tags string) string {
var candidates []string
for _, t := range strings.Split(tags, " ") {
if strings.HasPrefix(t, "v") {
candidates = append(candidates, t)
}
}
if len(candidates) == 0 {
return ""
}
return candidates[0]
}
var outTemplate = template.Must(template.New("").Parse(`package {{ .package }}
var Version = "{{ .version }}"
var HgSha = "{{ .sha }}"
var Build = "{{ .build }}"
`))
func main() {
sha := syscmd("hg", "id", "--id")
curVersion := getVersionTag(syscmd("hg", "id", "--tags"))
lastVersion := getVersionTag(syscmd("hg", "id", "--tags", "-r", "limit(last(ancestors(.)&tag('re:v.*'))|.,1)"))
modified := strings.HasSuffix(sha, "+")
jobID := os.Getenv("CI_JOB_ID")
var version string
switch {
case modified && lastVersion != "":
version = lastVersion + ""
case curVersion != "":
version = curVersion
case lastVersion != "":
version = lastVersion + "-" + sha
default:
version = "v0.0.0-" + sha
}
vars := map[string]string{
"package": "main",
"sha": sha,
"version": version,
"build": jobID,
}
out, err := os.Create("version.go")
if err != nil {
panic(err)
}
if err := outTemplate.Execute(out, vars); err != nil {
panic(err)
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment