Andrey Smirnov 6f409ecd91
fix: drop extractParams function
In v0.7.5 there is a bug - if the final page of the wizard is reloaded,
the 'internal server error' is raised.

This was due to the type mismatch on the `wizardFinal` return type and
`extractParams` expected input type. This is bad development habbit to
have two types which should be kept in sync, so instead just drop this
function completely, as we don't need it.

Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
2025-07-24 15:15:11 +04:00

781 lines
20 KiB
Go

// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package http
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"slices"
"strings"
"github.com/blang/semver/v4"
"github.com/julienschmidt/httprouter"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/siderolabs/gen/maps"
"github.com/siderolabs/gen/value"
"github.com/siderolabs/gen/xslices"
"github.com/siderolabs/talos/pkg/machinery/constants"
"github.com/siderolabs/talos/pkg/machinery/imager/quirks"
"github.com/siderolabs/talos/pkg/machinery/platforms"
"gopkg.in/yaml.v3"
"github.com/siderolabs/image-factory/internal/artifacts"
"github.com/siderolabs/image-factory/internal/version"
"github.com/siderolabs/image-factory/pkg/schematic"
)
var templateFuncs template.FuncMap
func init() {
templateFuncs = template.FuncMap{
"dict": func(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
},
"short_version": func(version string) string {
v, err := semver.ParseTolerant(version)
if err != nil {
return version
}
return fmt.Sprintf("v%d.%d", v.Major, v.Minor)
},
"in": func(haystack []string, needle string) bool {
return slices.Index(haystack, needle) != -1
},
"dynamic_template": func(name string, in any) (template.HTML, error) {
var out bytes.Buffer
if err := getTemplates().ExecuteTemplate(&out, name, in); err != nil {
return "", err
}
return template.HTML(out.String()), nil
},
"version_less": func(a, b string) (bool, error) {
av, err := semver.ParseTolerant(a)
if err != nil {
return false, fmt.Errorf("error parsing version %q: %w", a, err)
}
bv, err := semver.ParseTolerant(b)
if err != nil {
return false, fmt.Errorf("error parsing version %q: %w", b, err)
}
return av.LT(bv), nil
},
"t": func(localizer *i18n.Localizer, key string) string {
translated, err := localizer.Localize(&i18n.LocalizeConfig{MessageID: key})
if err != nil {
return "missing translation"
}
return translated
},
}
}
// Target constants.
const (
TargetMetal = "metal"
TargetCloud = "cloud"
TargetSBC = "sbc"
)
// handleUI handles '/'.
func (f *Frontend) handleUI(ctx context.Context, w http.ResponseWriter, r *http.Request, _ httprouter.Params) error {
if r.Method == http.MethodHead {
return nil
}
if r.URL.Query().Has("lang") {
lang := r.URL.Query().Get("lang")
if lang == "" {
http.Error(w, "missing lang param", http.StatusBadRequest)
return nil
}
http.SetCookie(w, &http.Cookie{
Name: "lang",
Value: lang,
Path: "/",
MaxAge: 60 * 60 * 24 * 365,
HttpOnly: true,
})
returnURL := r.URL
query := returnURL.Query()
query.Del("lang")
returnURL.RawQuery = query.Encode()
w.Header().Set("Hx-Redirect", returnURL.String())
return nil
}
templateName, data, _, err := f.wizard(ctx, r, f.getLocalizer(r))
if err != nil {
return err
}
var buf bytes.Buffer
if err = getTemplates().ExecuteTemplate(&buf, templateName+".html", data); err != nil {
return err
}
return getTemplates().ExecuteTemplate(w, "index.html", struct {
Version string
WizardHTML template.HTML
Localizer *i18n.Localizer
Bundle *i18n.Bundle
Lang string
}{
Version: version.Tag,
WizardHTML: template.HTML(buf.String()),
Localizer: f.getLocalizer(r),
Bundle: getLocalizerBundle(),
Lang: getCurrentLang(r),
})
}
// WizardParams encapsulates the parameters of the wizard.
//
// Some fields might be not set if we haven't reached that step yet.
type WizardParams struct { //nolint:govet
Target string
Version string
Arch string
Platform string
Board string
SecureBoot string
Extensions []string
Cmdline string
CmdlineSet bool
OverlayOptions string
SelectedTarget string
SelectedVersion string
SelectedArch string
SelectedPlatform string
SelectedBoard string
SelectedSecureBoot string
SelectedExtensions []string
SelectedCmdline string
SelectedOverlayOptions string
// Dynamically set fields.
PlatformMeta platforms.Platform
BoardMeta platforms.SBC
TalosctlMeta Talosctl
// Localizer
Localizer *i18n.Localizer
}
// Talosctl provides methods to generate paths for talosctl binaries.
type Talosctl struct{}
// TalosctlPaths generates paths for talosctl binaries based on the provided tuples.
func (Talosctl) TalosctlPaths(tuples []artifacts.TalosctlTuple) []string {
paths := make([]string, 0, len(tuples))
for _, tuple := range tuples {
path := fmt.Sprintf("talosctl-%s-%s%s", tuple.OS, tuple.Arch, tuple.Ext)
paths = append(paths, path)
}
slices.Sort(paths)
return paths
}
func getCurrentLang(r *http.Request) string {
lang := r.URL.Query().Get("lang")
if lang == "" {
if cookie, err := r.Cookie("lang"); err == nil {
lang = cookie.Value
}
}
if lang == "" {
lang = "en"
}
return lang
}
// WizardParamsFromRequest extracts the wizard parameters from the request.
func WizardParamsFromRequest(r *http.Request) WizardParams {
params := WizardParams{
Target: r.FormValue("target"),
Version: r.FormValue("version"),
Arch: r.FormValue("arch"),
Platform: r.FormValue("platform"),
Board: r.FormValue("board"),
SecureBoot: r.FormValue("secureboot"),
Extensions: r.Form["extensions"],
Cmdline: strings.TrimSpace(r.FormValue("cmdline")),
CmdlineSet: r.FormValue("cmdline-set") != "",
OverlayOptions: strings.TrimSpace(r.FormValue("overlay-options")),
SelectedTarget: r.FormValue("selected-target"),
SelectedVersion: r.FormValue("selected-version"),
SelectedArch: r.FormValue("selected-arch"),
SelectedPlatform: r.FormValue("selected-platform"),
SelectedBoard: r.FormValue("selected-board"),
SelectedSecureBoot: r.FormValue("selected-secureboot"),
SelectedExtensions: r.Form["selected-extensions"],
SelectedCmdline: r.FormValue("selected-cmdline"),
SelectedOverlayOptions: r.FormValue("selected-overlay-options"),
}
switch {
case params.Target == TargetMetal:
params.Platform = constants.PlatformMetal
params.PlatformMeta = platforms.MetalPlatform()
case params.Target == TargetSBC:
params.Platform = constants.PlatformMetal
if params.Board != "" {
if idx := slices.IndexFunc(platforms.SBCs(), func(p platforms.SBC) bool {
return p.Name == params.Board
}); idx != -1 {
params.BoardMeta = platforms.SBCs()[idx]
}
if params.Arch == "" {
if params.SelectedArch != "" {
params.SelectedBoard, params.Board = params.Board, ""
} else {
params.Arch = string(artifacts.ArchArm64)
}
}
}
case params.Target == TargetCloud && params.Platform != "":
if idx := slices.IndexFunc(platforms.CloudPlatforms(), func(p platforms.Platform) bool {
return p.Name == params.Platform
}); idx != -1 {
params.PlatformMeta = platforms.CloudPlatforms()[idx]
if len(params.PlatformMeta.Architectures) == 1 && params.Arch == "" {
if params.SelectedArch != "" {
// going back, reset platform choice
params.SelectedPlatform, params.Platform = params.Platform, ""
} else {
params.Arch = params.PlatformMeta.Architectures[0]
}
}
}
}
return params
}
// URLValues returns the URL values of the wizard parameters.
func (p WizardParams) URLValues() url.Values {
values := url.Values{}
if p.Target != "" {
values.Set("target", p.Target)
}
if p.Version != "" {
values.Set("version", p.Version)
}
if p.Arch != "" {
values.Set("arch", p.Arch)
}
if p.Platform != "" {
values.Set("platform", p.Platform)
}
if p.Board != "" {
values.Set("board", p.Board)
}
if p.SecureBoot != "" {
values.Set("secureboot", p.SecureBoot)
}
if len(p.Extensions) > 0 {
values["extensions"] = p.Extensions
}
if p.Cmdline != "" {
values.Set("cmdline", p.Cmdline)
}
if p.CmdlineSet {
values.Set("cmdline-set", "true")
}
if p.OverlayOptions != "" {
values.Set("overlay-options", p.OverlayOptions)
}
if len(values) == 0 {
return nil
}
return values
}
// wizardVersions handles the 'pick Talos version' step.
func (f *Frontend) wizardVersions(ctx context.Context, params WizardParams) (string, any, url.Values, error) {
versions, err := f.getTalosVersions(ctx, params.SelectedVersion)
if err != nil {
return "", nil, nil, err
}
return "wizard-versions",
struct {
WizardParams
Versions any
}{
WizardParams: params,
Versions: versions,
},
params.URLValues(),
nil
}
// wizardClouds handles the 'pick cloud platform' step.
func (f *Frontend) wizardClouds(_ context.Context, params WizardParams) (string, any, url.Values, error) {
if params.SelectedPlatform == "" {
params.SelectedPlatform = "aws"
}
talosVersion, _ := semver.ParseTolerant(params.Version) //nolint:errcheck
allPlatforms := platforms.CloudPlatforms()
allPlatforms = xslices.Filter(allPlatforms, func(p platforms.Platform) bool {
if value.IsZero(&p.MinVersion) {
return true
}
return talosVersion.GTE(p.MinVersion)
})
return "wizard-cloud",
struct {
WizardParams
Platforms []platforms.Platform
}{
WizardParams: params,
Platforms: allPlatforms,
},
params.URLValues(),
nil
}
// wizardSBCs handles the 'pick SBC' step.
func (f *Frontend) wizardSBCs(_ context.Context, params WizardParams) (string, any, url.Values, error) {
if params.SelectedBoard == "" {
params.SelectedBoard = "rpi_generic"
}
talosVersion, _ := semver.ParseTolerant(params.Version) //nolint:errcheck
allSBCs := platforms.SBCs()
allSBCs = xslices.Filter(allSBCs, func(p platforms.SBC) bool {
if value.IsZero(&p.MinVersion) {
return true
}
return talosVersion.GTE(p.MinVersion)
})
return "wizard-sbc",
struct {
WizardParams
SBCs []platforms.SBC
}{
WizardParams: params,
SBCs: allSBCs,
},
params.URLValues(),
nil
}
// wizardArch handles the 'pick architecture' step.
func (f *Frontend) wizardArch(_ context.Context, params WizardParams) (string, any, url.Values, error) {
talosVersion, _ := semver.ParseTolerant(params.Version) //nolint:errcheck
if params.SelectedArch == "" {
params.SelectedArch = "amd64"
}
return "wizard-arch",
struct {
WizardParams
SecureBootSupported bool
}{
WizardParams: params,
SecureBootSupported: talosVersion.GTE(semver.MustParse("1.5.0")) && (params.Target == TargetMetal || params.PlatformMeta.SecureBootSupported),
},
params.URLValues(),
nil
}
// wizardExtensions handles the 'pick extensions' step.
func (f *Frontend) wizardExtensions(ctx context.Context, params WizardParams) (string, any, url.Values, error) {
extensions, err := f.getOfficialExtensions(ctx, params.Version)
if err != nil {
return "", nil, nil, err
}
return "wizard-extensions",
struct {
WizardParams
AvailableExtensions []artifacts.ExtensionRef
}{
WizardParams: params,
AvailableExtensions: extensions,
},
params.URLValues(),
nil
}
// wizardCmdline handles the 'pick cmdline & overlay options' step.
func (f *Frontend) wizardCmdline(_ context.Context, params WizardParams) (string, any, url.Values, error) {
return "wizard-cmdline",
struct {
WizardParams
OverlayOptionsEnabled bool
}{
WizardParams: params,
OverlayOptionsEnabled: params.Target == TargetSBC && quirks.New(params.Version).SupportsOverlay(),
},
params.URLValues(),
nil
}
// wizardFinal handles the 'final' step.
func (f *Frontend) wizardFinal(ctx context.Context, params WizardParams) (string, any, url.Values, error) {
talosVersion, _ := semver.ParseTolerant(params.Version) //nolint:errcheck
// every parameter is set now, create the schematic
var extraArgs []string
if params.Cmdline != "" {
extraArgs = strings.Split(params.Cmdline, " ")
}
extensions := xslices.Filter(params.Extensions, func(ext string) bool {
return ext != "-"
})
slices.Sort(extensions)
var overlay schematic.Overlay
if params.Target == TargetSBC && quirks.New(params.Version).SupportsOverlay() {
overlay.Name = params.BoardMeta.OverlayName
overlay.Image = params.BoardMeta.OverlayImage
var overlayOptsParsed map[string]any
if err := yaml.Unmarshal([]byte(params.OverlayOptions), &overlayOptsParsed); err != nil {
return "", nil, nil, fmt.Errorf("error parsing overlay options: %w", err)
}
overlay.Options = overlayOptsParsed
}
requestedSchematic := schematic.Schematic{
Overlay: overlay,
Customization: schematic.Customization{
ExtraKernelArgs: extraArgs,
SystemExtensions: schematic.SystemExtensions{
OfficialExtensions: extensions,
},
},
}
schematicID, err := f.schematicFactory.Put(ctx, &requestedSchematic)
if err != nil {
return "", nil, nil, err
}
marshaled, err := requestedSchematic.Marshal()
if err != nil {
return "", nil, nil, err
}
version := "v" + params.Version
installerImage := fmt.Sprintf("%s/installer/%s:%s", f.options.ExternalURL.Host, schematicID, version)
secureBootInstallerImage := fmt.Sprintf("%s/installer-secureboot/%s:%s", f.options.ExternalURL.Host, schematicID, version)
if quirks.New(version).SupportsUnifiedInstaller() {
installerImage = fmt.Sprintf("%s/%s-installer/%s:%s", f.options.ExternalURL.Host, params.Platform, schematicID, version)
secureBootInstallerImage = fmt.Sprintf("%s/%s-installer-secureboot/%s:%s", f.options.ExternalURL.Host, params.Platform, schematicID, version)
}
var talosctlTuples []artifacts.TalosctlTuple
if talosVersion.GTE(semver.MustParse("1.11.0-alpha.3")) {
talosctlTuples, err = f.getTalosctlTuples(ctx, params.Version)
if err != nil {
return "", nil, nil, err
}
}
return "wizard-final",
struct {
WizardParams
Schematic string
Marshaled string
ImageBaseURL *url.URL
PXEBaseURL *url.URL
TalosctlBaseURL *url.URL
InstallerImage string
SecureBootInstallerImage string
TalosctlTuples []artifacts.TalosctlTuple
TroubleshootingGuideAvailable bool
ProductionGuideAvailable bool
TalosctlAvailable bool
}{
WizardParams: params,
Schematic: schematicID,
Marshaled: string(marshaled),
ImageBaseURL: f.options.ExternalURL.JoinPath("image", schematicID, version),
PXEBaseURL: f.options.ExternalPXEURL.JoinPath("pxe", schematicID, version),
TalosctlBaseURL: f.options.ExternalURL.JoinPath("talosctl", version),
InstallerImage: installerImage,
SecureBootInstallerImage: secureBootInstallerImage,
TalosctlTuples: talosctlTuples,
TroubleshootingGuideAvailable: talosVersion.GTE(semver.MustParse("1.6.0")),
ProductionGuideAvailable: talosVersion.GTE(semver.MustParse("1.5.0")),
TalosctlAvailable: talosVersion.GTE(semver.MustParse("1.11.0-alpha.3")),
},
params.URLValues(),
nil
}
func (f *Frontend) wizard(ctx context.Context, r *http.Request, localizer *i18n.Localizer) (string, any, url.Values, error) {
params := WizardParamsFromRequest(r)
params.Localizer = localizer
switch {
case params.Target == "":
if params.SelectedTarget == "" {
params.SelectedTarget = TargetMetal
}
return "wizard-start", params, nil, nil
case params.Version == "":
return f.wizardVersions(ctx, params)
case params.Target == TargetCloud && params.Platform == "":
return f.wizardClouds(ctx, params)
case params.Target == TargetSBC && params.Board == "":
return f.wizardSBCs(ctx, params)
case params.Arch == "":
return f.wizardArch(ctx, params)
case len(params.Extensions) == 0:
return f.wizardExtensions(ctx, params)
case !params.CmdlineSet:
return f.wizardCmdline(ctx, params)
default:
return f.wizardFinal(ctx, params)
}
}
// handleUIWizard handles '/ui/wizard'.
func (f *Frontend) handleUIWizard(ctx context.Context, w http.ResponseWriter, r *http.Request, _ httprouter.Params) error {
templateName, data, query, err := f.wizard(ctx, r, f.getLocalizer(r))
if err != nil {
return err
}
if query != nil {
w.Header().Set("Hx-Push-Url", "/?"+query.Encode())
} else {
w.Header().Set("Hx-Push-Url", "/")
}
return getTemplates().ExecuteTemplate(w, templateName+".html", data)
}
// handleUIWizard handles '/ui/extensions-list'.
func (f *Frontend) handleUIExtensionsList(ctx context.Context, w http.ResponseWriter, r *http.Request, _ httprouter.Params) error {
version := r.FormValue("version")
filter := r.FormValue("search")
extensions := r.Form["extensions"]
extensionList, err := f.getOfficialExtensions(ctx, version)
if err != nil {
return err
}
if filter != "" {
extensionList = xslices.Filter(extensionList, func(ext artifacts.ExtensionRef) bool {
if slices.Index(extensions, ext.TaggedReference.RepositoryStr()) != -1 {
// selected
return true
}
if strings.Contains(strings.ToLower(ext.TaggedReference.String()), strings.ToLower(filter)) {
return true
}
if strings.Contains(strings.ToLower(ext.Description), strings.ToLower(filter)) {
return true
}
return false
})
}
return getTemplates().ExecuteTemplate(w, "extensions-list.html", struct {
SelectedExtensions []string
AvailableExtensions []artifacts.ExtensionRef
}{
SelectedExtensions: extensions,
AvailableExtensions: extensionList,
})
}
func (f *Frontend) getTalosVersions(ctx context.Context, selectedVersion string) (any, error) {
versions, err := f.artifactsManager.GetTalosVersions(ctx)
if err != nil {
return nil, err
}
versions = slices.Clone(versions)
slices.Reverse(versions)
var latestStable semver.Version
for _, v := range versions {
if len(v.Pre) == 0 {
latestStable = v
break
}
}
if selectedVersion == "" {
selectedVersion = latestStable.String()
}
type versionGroup struct {
Label string
Versions []string
}
type versionList struct {
DefaultVersion string
LatestStable string
Groups []versionGroup
}
versionGroupLabel := func(v semver.Version) string {
if len(v.Pre) > 0 {
return fmt.Sprintf("%d.%d-pre", v.Major, v.Minor)
}
return fmt.Sprintf("%d.%d", v.Major, v.Minor)
}
groups := map[string][]semver.Version{}
for _, v := range versions {
label := versionGroupLabel(v)
groups[label] = append(groups[label], v)
}
groupLabels := maps.Keys(groups)
slices.SortFunc(groupLabels, func(a, b string) int {
va, _ := semver.ParseTolerant(a) //nolint:errcheck
vb, _ := semver.ParseTolerant(b) //nolint:errcheck
return -va.Compare(vb)
})
return versionList{
DefaultVersion: selectedVersion,
LatestStable: latestStable.String(),
Groups: xslices.Map(groupLabels, func(label string) versionGroup {
return versionGroup{
Label: label,
Versions: xslices.Map(groups[label], semver.Version.String),
}
}),
}, nil
}
// handleUIVersionDoc handles '/ui/version-doc'.
func (f *Frontend) handleUIVersionDoc(_ context.Context, w http.ResponseWriter, r *http.Request, _ httprouter.Params) error {
version := r.FormValue("version")
return getTemplates().ExecuteTemplate(w, "version-doc.html", struct {
Localizer *i18n.Localizer
Version string
}{
Version: version,
Localizer: f.getLocalizer(r),
})
}
func (f *Frontend) getOfficialExtensions(ctx context.Context, version string) ([]artifacts.ExtensionRef, error) {
extensions, err := f.artifactsManager.GetOfficialExtensions(ctx, version)
if err != nil {
return nil, err
}
return xslices.Filter(extensions, func(ext artifacts.ExtensionRef) bool {
return ext.TaggedReference.Context().RepositoryStr() != "siderolabs/metal-agent" // hide the internal metal-agent extension on the UI
}), nil
}
func (f *Frontend) getTalosctlTuples(ctx context.Context, version string) ([]artifacts.TalosctlTuple, error) {
talosctlTuples, err := f.artifactsManager.GetTalosctlTuples(ctx, version)
if err != nil {
return nil, err
}
return talosctlTuples, nil
}