Skip to content
1 change: 1 addition & 0 deletions cmd/builder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func uploadLog(logPath string, id string) {
func sendBuildNotification(taskUUID, status string, jobInfo notification.JobNotificationInfo) {
notification.SendJobNotification(
irgshConfig.Notification.WebhookURL,
irgshConfig.FullBaseURL,
"Build",
taskUUID,
status,
Expand Down
10 changes: 9 additions & 1 deletion cmd/chief/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,16 @@ func setupRoutes(cfg config.IrgshConfig, artifactEP *artifactEndpoint.ArtifactHT
submissionFs := http.FileServer(http.Dir(cfg.Chief.Workdir + "/submissions"))
mux.Handle("/submissions/", http.StripPrefix("/submissions/", submissionFs))

// rootMux forwards prefixed browser traffic to mux while also keeping mux
// reachable at root so workers can call /api/v1/* without config changes.
rootMux := http.NewServeMux()
if cfg.Chief.BaseURL != "" {
rootMux.Handle(cfg.Chief.BaseURL+"/", http.StripPrefix(cfg.Chief.BaseURL, mux))
}
rootMux.Handle("/", mux)

return &http.Server{
Handler: mux,
Handler: rootMux,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 15 * time.Second,
IdleTimeout: 90 * time.Second,
Expand Down
1 change: 1 addition & 0 deletions cmd/iso/iso.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func uploadLog(logPath string, id string) {
func sendISONotification(taskUUID, status string, jobInfo notification.JobNotificationInfo) {
notification.SendJobNotification(
irgshConfig.Notification.WebhookURL,
irgshConfig.FullBaseURL,
"ISO Build",
taskUUID,
status,
Expand Down
1 change: 1 addition & 0 deletions cmd/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func uploadLog(logPath string, id string) {
func sendRepoNotification(taskUUID, status string, jobInfo notification.JobNotificationInfo) {
notification.SendJobNotification(
irgshConfig.Notification.WebhookURL,
irgshConfig.FullBaseURL,
"Repo",
taskUUID,
status,
Expand Down
6 changes: 3 additions & 3 deletions internal/chief/usecase/chief.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func NewChiefUsecase(
version string,
) (*ChiefUsecase, error) {
maintainerSvc := NewMaintainerService(gpg)
dashSvc, err := newDashboardSvc(version, taskQueue, maintainerSvc, registry)
dashSvc, err := newDashboardSvc(version, cfg.Chief.BaseURL, taskQueue, maintainerSvc, registry)
if err != nil {
return nil, fmt.Errorf("init dashboard service: %w", err)
}
Expand Down Expand Up @@ -64,7 +64,7 @@ func newSubmissionSvc(tq TaskQueue, st FileStorage, gpg GPGVerifier, reg *monito
return NewSubmissionService(tq, st, gpg, js, is)
}

func newDashboardSvc(version string, tq TaskQueue, ms *MaintainerService, reg *monitoring.Registry) (*DashboardService, error) {
func newDashboardSvc(version string, baseURL string, tq TaskQueue, ms *MaintainerService, reg *monitoring.Registry) (*DashboardService, error) {
var ir InstanceRegistry
var js JobStore
var is ISOJobStore
Expand All @@ -73,7 +73,7 @@ func newDashboardSvc(version string, tq TaskQueue, ms *MaintainerService, reg *m
js = reg
is = reg
}
return NewDashboardService(version, tq, ms, ir, js, is)
return NewDashboardService(version, baseURL, tq, ms, ir, js, is)
}

// GetVersion returns the version string for use by handlers.
Expand Down
11 changes: 10 additions & 1 deletion internal/chief/usecase/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ var dashboardTmplStr string

type DashboardData struct {
Version string
BaseURL string
Maintainers []domain.Maintainer
HasMonitoring bool
Summary SummaryView
Expand Down Expand Up @@ -93,6 +94,7 @@ type ISOJobView struct {
// DashboardService renders the chief dashboard HTML.
type DashboardService struct {
version string
baseURL string
taskQueue TaskQueue
maintainerSvc *MaintainerService
registry InstanceRegistry
Expand All @@ -103,18 +105,24 @@ type DashboardService struct {

func NewDashboardService(
version string,
baseURL string,
taskQueue TaskQueue,
maintainerSvc *MaintainerService,
registry InstanceRegistry,
jobStore JobStore,
isoStore ISOJobStore,
) (*DashboardService, error) {
tmpl, err := template.New("dashboard").Parse(dashboardTmplStr)
tmpl, err := template.New("dashboard").Funcs(template.FuncMap{
"baseurl": func(p string) string {
return baseURL + p
},
}).Parse(dashboardTmplStr)
if err != nil {
return nil, fmt.Errorf("parse dashboard template: %w", err)
}
return &DashboardService{
version: version,
baseURL: baseURL,
taskQueue: taskQueue,
maintainerSvc: maintainerSvc,
registry: registry,
Expand All @@ -132,6 +140,7 @@ func (d *DashboardService) RenderIndexHTML(w io.Writer) error {
func (d *DashboardService) buildDashboardData() DashboardData {
data := DashboardData{
Version: d.version,
BaseURL: d.baseURL,
Maintainers: d.maintainerSvc.GetMaintainers(),
}

Expand Down
2 changes: 1 addition & 1 deletion internal/chief/usecase/dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ func TestDashboardService_RenderIndexHTML(t *testing.T) {
}
maintainerSvc := NewMaintainerService(gpg)

ds, err := NewDashboardService("1.0.0", &mockTaskQueue{}, maintainerSvc, nil, nil, nil)
ds, err := NewDashboardService("1.0.0", "/irgsh", &mockTaskQueue{}, maintainerSvc, nil, nil, nil)
require.NoError(t, err)

var buf bytes.Buffer
Expand Down
4 changes: 2 additions & 2 deletions internal/chief/usecase/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,8 @@
<td>{{.PackageVersion}}</td>
<td>{{.Maintainer}}</td>
<td>{{.Component}}</td>
<td><span class="{{.BuildStageClass}}">{{.BuildStateText}}</span><br><a href="/logs/{{.TaskUUID}}.build.log" target="_blank" style="font-size:0.85em;">log</a></td>
<td><span class="{{.RepoStageClass}}">{{.RepoStateText}}</span><br><a href="/logs/{{.TaskUUID}}.repo.log" target="_blank" style="font-size:0.85em;">log</a></td>
<td><span class="{{.BuildStageClass}}">{{.BuildStateText}}</span><br><a href="{{baseurl (printf "/logs/%s.build.log" .TaskUUID)}}" target="_blank" style="font-size:0.85em;">log</a></td>
<td><span class="{{.RepoStageClass}}">{{.RepoStateText}}</span><br><a href="{{baseurl (printf "/logs/%s.repo.log" .TaskUUID)}}" target="_blank" style="font-size:0.85em;">log</a></td>
<td>
{{- if .ShowSpinner}}
<svg class="spinning-gear" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ff9800" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Expand Down
43 changes: 40 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import (
"fmt"
"log"
"os"
"regexp"
"strings"

"github.com/ghodss/yaml"
validator "gopkg.in/go-playground/validator.v9"
)

var baseURLRegex = regexp.MustCompile(`^/[A-Za-z0-9_\-/]*$`)

type IrgshConfig struct {
Redis string `json:"redis"`
Chief ChiefConfig `json:"chief"`
Expand All @@ -21,12 +24,17 @@ type IrgshConfig struct {
Storage StorageConfig `json:"storage"`
IsTest bool `json:"is_test"`
IsDev bool `json:"is_dev"`
// FullBaseURL is the externally-reachable base URL for log links, computed
// once at load time so workers don't repeat the public_url/base_url check.
FullBaseURL string `json:"-"`
}

type ChiefConfig struct {
Address string `json:"address" validate:"required"`
Workdir string `json:"workdir" validate:"required"`
GnupgDir string `json:"gnupg_dir" validate:"required"` // GNUPG dir path
Address string `json:"address" validate:"required"`
BaseURL string `json:"base_url" validate:"baseurl"`
PublicURL string `json:"public_url"`
Workdir string `json:"workdir" validate:"required"`
GnupgDir string `json:"gnupg_dir" validate:"required"` // GNUPG dir path
}

type BuilderConfig struct {
Expand Down Expand Up @@ -169,6 +177,35 @@ func applyDefaults(cfg *IrgshConfig) error {
cfg.Monitoring.CleanupInterval = 3600
}

normalizeChiefConfig(&cfg.Chief)
cfg.FullBaseURL = computeFullBaseURL(&cfg.Chief)

validate := validator.New()
if err := validate.RegisterValidation("baseurl", func(fl validator.FieldLevel) bool {
return fl.Field().String() == "" || baseURLRegex.MatchString(fl.Field().String())
}); err != nil {
return err
}
return validate.Struct(cfg)
}

func normalizeChiefConfig(cfg *ChiefConfig) {
cfg.Address = strings.TrimSuffix(cfg.Address, "/")
cfg.PublicURL = strings.TrimSuffix(cfg.PublicURL, "/")

b := strings.TrimSuffix(cfg.BaseURL, "/")
if b != "" && !strings.HasPrefix(b, "/") {
b = "/" + b
}
cfg.BaseURL = b
}

// computeFullBaseURL returns the externally-reachable base URL for log links.
// When public_url is set it is treated as the complete external URL; otherwise
// the internal address is combined with base_url. Expects a normalized config.
func computeFullBaseURL(cfg *ChiefConfig) string {
if cfg.PublicURL != "" {
return cfg.PublicURL
}
return cfg.Address + cfg.BaseURL
}
166 changes: 166 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package config

import (
"strings"
"testing"
)

func TestNormalizeChiefConfig(t *testing.T) {
tests := []struct {
name string
input ChiefConfig
wantAddr string
wantBase string
wantPublic string
}{
{
name: "trailing slashes",
input: ChiefConfig{
Address: "http://localhost:8080/",
BaseURL: "/irgsh/",
PublicURL: "https://irgsh.id/",
},
wantAddr: "http://localhost:8080",
wantBase: "/irgsh",
wantPublic: "https://irgsh.id",
},
{
name: "missing leading slash on base_url",
input: ChiefConfig{
Address: "http://localhost:8080",
BaseURL: "irgsh",
PublicURL: "https://irgsh.id",
},
wantAddr: "http://localhost:8080",
wantBase: "/irgsh",
wantPublic: "https://irgsh.id",
},
{
name: "root base_url",
input: ChiefConfig{
Address: "http://localhost:8080",
BaseURL: "/",
PublicURL: "https://irgsh.id",
},
wantAddr: "http://localhost:8080",
wantBase: "",
wantPublic: "https://irgsh.id",
},
{
name: "empty values",
input: ChiefConfig{
Address: "http://localhost:8080",
BaseURL: "",
PublicURL: "",
},
wantAddr: "http://localhost:8080",
wantBase: "",
wantPublic: "",
},
{
name: "nested base_url",
input: ChiefConfig{
Address: "http://localhost:8080",
BaseURL: "/api/v1/",
PublicURL: "https://irgsh.id",
},
wantAddr: "http://localhost:8080",
wantBase: "/api/v1",
wantPublic: "https://irgsh.id",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := tt.input
normalizeChiefConfig(&cfg)
if cfg.Address != tt.wantAddr {
t.Errorf("normalizeChiefConfig() Address = %v, want %v", cfg.Address, tt.wantAddr)
}
if cfg.BaseURL != tt.wantBase {
t.Errorf("normalizeChiefConfig() BaseURL = %v, want %v", cfg.BaseURL, tt.wantBase)
}
if cfg.PublicURL != tt.wantPublic {
t.Errorf("normalizeChiefConfig() PublicURL = %v, want %v", cfg.PublicURL, tt.wantPublic)
}
})
}
}

func TestComputeFullBaseURL(t *testing.T) {
tests := []struct {
name string
input ChiefConfig
want string
}{
{
name: "public_url takes precedence",
input: ChiefConfig{Address: "http://localhost:8080", BaseURL: "/irgsh", PublicURL: "https://irgsh.id/irgsh"},
want: "https://irgsh.id/irgsh",
},
{
name: "fallback to address + base_url",
input: ChiefConfig{Address: "http://localhost:8080", BaseURL: "/irgsh", PublicURL: ""},
want: "http://localhost:8080/irgsh",
},
{
name: "fallback with empty base_url",
input: ChiefConfig{Address: "http://localhost:8080", BaseURL: "", PublicURL: ""},
want: "http://localhost:8080",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := tt.input
if got := computeFullBaseURL(&cfg); got != tt.want {
t.Errorf("computeFullBaseURL() = %v, want %v", got, tt.want)
}
})
}
}

func TestBaseURLValidation(t *testing.T) {
tests := []struct {
name string
baseURL string
wantErr bool
}{
{"valid", "/irgsh", false},
{"valid nested", "/api/v1", false},
{"valid with hyphen", "/irgsh-go", false},
{"valid with underscore", "/irgsh_go", false},
{"empty", "", false},
{"invalid space", "/irgsh go", true},
{"invalid query", "/irgsh?a=b", true},
{"invalid control", "/irgsh\n", true},
{"invalid protocol", "http://irgsh", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &IrgshConfig{
Chief: ChiefConfig{
Address: "http://localhost:8080",
Workdir: "/tmp",
GnupgDir: "/tmp",
BaseURL: tt.baseURL,
},
Builder: BuilderConfig{
Workdir: "/tmp",
UpstreamDistCodename: "sid",
UpstreamDistUrl: "http://deb.debian.org/debian",
},
}
err := applyDefaults(cfg)
if (err != nil) != tt.wantErr {
t.Errorf("applyDefaults() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "baseurl") {
t.Errorf("expected error to mention baseurl, got: %v", err)
}
}
})
}
}
Loading