diff --git a/.github/workflows/run.yaml b/.github/workflows/run.yaml index 7a50e4d..cf03658 100644 --- a/.github/workflows/run.yaml +++ b/.github/workflows/run.yaml @@ -157,7 +157,9 @@ jobs: - name: Generate static site run: | set -e # Exit on error - ./bin/cdprun --log-level debug sitegen --db downloads.db --out _site + ./bin/cdprun --config runtime-registry.yaml --log-level debug sitegen \ + --db downloads.db \ + --out _site echo "Site generation completed with exit code: $?" - name: Verify site generation diff --git a/cmd/sitegen/main.go b/cmd/sitegen/main.go index 4f0d2e1..dccb13a 100644 --- a/cmd/sitegen/main.go +++ b/cmd/sitegen/main.go @@ -9,6 +9,7 @@ import ( "github.com/urfave/cli/v2" appcli "github.com/clean-dependency-project/cdprun/internal/cli" + "github.com/clean-dependency-project/cdprun/internal/config" "github.com/clean-dependency-project/cdprun/internal/sitegen" "github.com/clean-dependency-project/cdprun/internal/storage" ) @@ -40,6 +41,11 @@ func main() { Usage: "log level (debug, info, warn, error)", EnvVars: []string{"SITEGEN_LOG_LEVEL"}, }, + &cli.StringFlag{ + Name: "unsupported-file", + Usage: "path to YAML/JSON file listing unsupported runtime version prefixes (overrides registry config)", + EnvVars: []string{"SITEGEN_UNSUPPORTED_FILE"}, + }, }, Action: runSitegen, } @@ -70,13 +76,21 @@ func runSitegen(c *cli.Context) error { } }() + // Resolve unsupported-versions file: flag overrides, else empty (no filtering). + unsupportedFile := c.String("unsupported-file") + unsupportedCfg, err := config.LoadUnsupportedConfig(unsupportedFile) + if err != nil { + return err + } + // Create generator generator := sitegen.NewGenerator(db, stdout) // Generate site opts := sitegen.GenerateOptions{ - OutputDir: c.String("out"), - DryRun: c.Bool("dry-run"), + OutputDir: c.String("out"), + DryRun: c.Bool("dry-run"), + UnsupportedVersions: unsupportedCfg, } if err := generator.Generate(context.Background(), opts); err != nil { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d0c9763..1b4ad69 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -155,6 +155,11 @@ func NewApp() *cli.App { Name: "dry-run", Usage: "validate without writing files", }, + &cli.StringFlag{ + Name: "unsupported-file", + Usage: "path to YAML/JSON file listing unsupported runtime version prefixes (overrides registry config)", + EnvVars: []string{"SITEGEN_UNSUPPORTED_FILE"}, + }, }, Action: sitegenCommand, }, @@ -955,6 +960,23 @@ func downloadSingleRuntime(c *cli.Context, manager *runtime.Manager, cfg *config } } + // Load unsupported-versions configuration if specified. + unsupportedCfg, unsuppErr := config.LoadUnsupportedConfig(cfg.Config.UnsupportedFile) + if unsuppErr != nil { + stderr.Warn("failed to load unsupported versions config", "unsupported_file", cfg.Config.UnsupportedFile, "error", unsuppErr) + unsupportedCfg = config.UnsupportedConfig{} + } else { + ruleCount := 0 + for _, rules := range unsupportedCfg { + ruleCount += len(rules) + } + stdout.Debug("loaded unsupported versions config", "unsupported_file", cfg.Config.UnsupportedFile, "rule_count", ruleCount) + } + + // For batch path (no explicit --version): skip versions marked unsupported. + // For explicit --version path: warn but still proceed (user intent override). + versionsToDownload = applyUnsupportedFilter(runtimeName, version, versionsToDownload, unsupportedCfg, stderr) + // Download all versions var allResults []DownloadResult totalSuccess := 0 @@ -1220,14 +1242,15 @@ func sitegenCommand(c *cli.Context) error { logLevel := ParseLogLevelOrDefault(c.String("log-level")) stdout, stderr := NewLoggers(logLevel) - // Determine database path + // Always load the registry config so we can read UnsupportedFile even when --db is supplied. + configPath := c.String("config") + cfg, cfgErr := config.LoadConfig(configPath) + + // Determine database path: flag takes precedence, then registry config. dbPath := c.String("db") if dbPath == "" { - // Try to get from config - configPath := c.String("config") - cfg, err := config.LoadConfig(configPath) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) + if cfgErr != nil { + return fmt.Errorf("failed to load config: %w", cfgErr) } dbPath = cfg.Config.Storage.DatabasePath if dbPath == "" { @@ -1235,6 +1258,19 @@ func sitegenCommand(c *cli.Context) error { } } + // Resolve unsupported-versions file: --unsupported-file flag overrides, then registry config. + unsupportedFile := c.String("unsupported-file") + if unsupportedFile == "" && cfgErr == nil { + unsupportedFile = cfg.Config.UnsupportedFile + } + unsupportedCfg, err := config.LoadUnsupportedConfig(unsupportedFile) + if err != nil { + return fmt.Errorf("failed to load unsupported versions config: %w", err) + } + if unsupportedFile != "" { + stdout.Info("loaded unsupported versions config", "file", unsupportedFile, "runtimes", len(unsupportedCfg)) + } + // Open database db, err := storage.InitDB(storage.Config{ DatabasePath: dbPath, @@ -1254,8 +1290,9 @@ func sitegenCommand(c *cli.Context) error { // Generate site opts := sitegen.GenerateOptions{ - OutputDir: outputDir, - DryRun: dryRun, + OutputDir: outputDir, + DryRun: dryRun, + UnsupportedVersions: unsupportedCfg, } if err := generator.Generate(c.Context, opts); err != nil { @@ -1265,3 +1302,56 @@ func sitegenCommand(c *cli.Context) error { stdout.Info("site generation completed successfully") return nil } + +// applyUnsupportedFilter applies the unsupported-versions policy to versionsToDownload. +// +// Batch mode (requestedVersion == ""): versions whose LatestPatch matches a rule are +// removed from the list and a structured Warn is emitted for each one. +// +// Explicit mode (requestedVersion != ""): versions are kept but a Warn is still emitted +// so operators are informed; the user knowingly requested an unsupported version. +func applyUnsupportedFilter( + runtimeName, requestedVersion string, + versions []runtime.VersionInfo, + uc config.UnsupportedConfig, + logger *slog.Logger, +) []runtime.VersionInfo { + if len(uc) == 0 { + return versions + } + if requestedVersion != "" { + // Explicit: warn only. + for _, vi := range versions { + if uc.IsVersionUnsupported(runtimeName, vi.LatestPatch) { + rule := uc.FindMatchingRule(runtimeName, vi.LatestPatch) + eolDate := "" + if rule != nil { + eolDate = rule.EOLDate + } + logger.Warn("downloading explicitly requested version that is unsupported", + "runtime", runtimeName, + "version", vi.LatestPatch, + "eol_date", eolDate) + } + } + return versions + } + // Batch: skip unsupported. + kept := versions[:0:0] + for _, vi := range versions { + if uc.IsVersionUnsupported(runtimeName, vi.LatestPatch) { + rule := uc.FindMatchingRule(runtimeName, vi.LatestPatch) + eolDate := "" + if rule != nil { + eolDate = rule.EOLDate + } + logger.Warn("skipping unsupported version", + "runtime", runtimeName, + "version", vi.LatestPatch, + "eol_date", eolDate) + continue + } + kept = append(kept, vi) + } + return kept +} diff --git a/internal/cli/unsupported_filter_test.go b/internal/cli/unsupported_filter_test.go new file mode 100644 index 0000000..ca55726 --- /dev/null +++ b/internal/cli/unsupported_filter_test.go @@ -0,0 +1,98 @@ +package cli + +import ( + "bytes" + "log/slog" + "testing" + + "github.com/clean-dependency-project/cdprun/internal/config" + "github.com/clean-dependency-project/cdprun/internal/runtime" +) + +func makeVersionInfos(latestPatches ...string) []runtime.VersionInfo { + vis := make([]runtime.VersionInfo, len(latestPatches)) + for i, lp := range latestPatches { + vis[i] = runtime.VersionInfo{Version: lp, LatestPatch: lp} + } + return vis +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)) +} + +func TestApplyUnsupportedFilter_BatchSkip(t *testing.T) { + uc := config.UnsupportedConfig{ + "nodejs": { + {Version: "16", EOLDate: "2023-09-11"}, + {Version: "14", EOLDate: "2023-04-30"}, + }, + } + versions := makeVersionInfos("22.15.0", "20.19.1", "16.20.2", "14.21.3") + + got := applyUnsupportedFilter("nodejs", "", versions, uc, discardLogger()) + + if len(got) != 2 { + t.Fatalf("expected 2 versions after batch filter, got %d: %v", len(got), got) + } + for _, vi := range got { + if vi.LatestPatch == "16.20.2" || vi.LatestPatch == "14.21.3" { + t.Errorf("unsupported version %q should have been filtered out", vi.LatestPatch) + } + } +} + +func TestApplyUnsupportedFilter_ExplicitWarnOnly(t *testing.T) { + uc := config.UnsupportedConfig{ + "nodejs": {{Version: "16", EOLDate: "2023-09-11"}}, + } + // Explicit --version path: version is NOT empty. + versions := makeVersionInfos("16.20.2") + + got := applyUnsupportedFilter("nodejs", "16", versions, uc, discardLogger()) + + // Version must be KEPT (warn only, not removed). + if len(got) != 1 { + t.Fatalf("expected 1 version (kept) for explicit path, got %d", len(got)) + } + if got[0].LatestPatch != "16.20.2" { + t.Errorf("expected 16.20.2 to be kept, got %q", got[0].LatestPatch) + } +} + +func TestApplyUnsupportedFilter_EmptyConfig(t *testing.T) { + versions := makeVersionInfos("22.15.0", "20.19.1", "16.20.2") + + got := applyUnsupportedFilter("nodejs", "", versions, config.UnsupportedConfig{}, discardLogger()) + + if len(got) != 3 { + t.Errorf("expected all 3 versions to pass through empty config, got %d", len(got)) + } +} + +func TestApplyUnsupportedFilter_UnknownRuntime(t *testing.T) { + uc := config.UnsupportedConfig{ + "nodejs": {{Version: "16", EOLDate: "2023-09-11"}}, + } + versions := makeVersionInfos("8.11.0") // "tomcat" has no rules + + got := applyUnsupportedFilter("tomcat", "", versions, uc, discardLogger()) + + if len(got) != 1 { + t.Errorf("expected 1 version for unknown runtime, got %d", len(got)) + } +} + +func TestApplyUnsupportedFilter_FalsePositive(t *testing.T) { + uc := config.UnsupportedConfig{ + "nodejs": {{Version: "16", EOLDate: "2023-09-11"}}, + } + // "160.0.1" must NOT be filtered by prefix "16" + versions := makeVersionInfos("160.0.1", "22.15.0") + + got := applyUnsupportedFilter("nodejs", "", versions, uc, discardLogger()) + + if len(got) != 2 { + t.Errorf("expected 160.0.1 to NOT be filtered by prefix '16', got %v", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 3fac3bc..7a9eaf6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,7 +52,8 @@ type GlobalConfig struct { DownloadTimeout string `yaml:"download_timeout"` AutoDownloadAllPlatforms bool `yaml:"auto_download_all_platforms"` Storage StorageConfig `yaml:"storage"` - IgnoreFile string `yaml:"ignore_file"` // Path to JSON file listing versions to ignore per runtime + IgnoreFile string `yaml:"ignore_file"` // Path to file listing platform-specific versions to ignore + UnsupportedFile string `yaml:"unsupported_file"` // Path to file listing unsupported runtime version prefixes PackagingExecution PackagingExecutionConfig `yaml:"packaging_execution"` } diff --git a/internal/config/unsupported.go b/internal/config/unsupported.go new file mode 100644 index 0000000..c07776b --- /dev/null +++ b/internal/config/unsupported.go @@ -0,0 +1,95 @@ +// Package config provides configuration management for the unified runtime download system. +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// UnsupportedVersionInput represents a single unsupported version rule as declared +// by the operator in the unsupported-versions YAML/JSON input file. +// The Version field uses prefix matching (same semantics as ignore-versions.yaml). +// Reason and EOLDate are author-friendly names; they are mapped to PolicyVersion +// fields (notes, eol) when serialised into the sitegen JSON output. +type UnsupportedVersionInput struct { + Version string `yaml:"version" json:"version"` + Reason string `yaml:"reason,omitempty" json:"reason,omitempty"` + EOLDate string `yaml:"eol_date,omitempty" json:"eol_date,omitempty"` +} + +// UnsupportedConfig maps runtime names to their list of unsupported version rules. +// Example: +// +// nodejs: +// - version: "16" +// reason: "EOL since 2023-09-11" +// eol_date: "2023-09-11" +type UnsupportedConfig map[string][]UnsupportedVersionInput + +// LoadUnsupportedConfig loads an unsupported-versions configuration file. +// Supports both YAML (.yaml/.yml) and JSON (.json) formats, detected by extension. +// Returns an empty config if filePath is empty. +func LoadUnsupportedConfig(filePath string) (UnsupportedConfig, error) { + if filePath == "" { + return UnsupportedConfig{}, nil + } + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read unsupported versions file %s: %w", filePath, err) + } + var raw map[string][]UnsupportedVersionInput + switch ext := filepath.Ext(filePath); ext { + case ".yaml", ".yml": + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse YAML unsupported versions file %s: %w", filePath, err) + } + default: + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse JSON unsupported versions file %s: %w", filePath, err) + } + } + return UnsupportedConfig(raw), nil +} + +// IsVersionUnsupported returns true if the given version matches any unsupported +// rule for the runtime using prefix matching. +// "16" matches "16.20.2" but not "160.0.1" — the prefix must be followed by a +// non-digit character or be an exact match, mirroring the semantics of IgnoreConfig. +func (uc UnsupportedConfig) IsVersionUnsupported(runtime, version string) bool { + return uc.FindMatchingRule(runtime, version) != nil +} + +// FindMatchingRule returns the first rule whose Version prefix matches the given +// concrete version for the runtime, or nil if no rule matches. +func (uc UnsupportedConfig) FindMatchingRule(runtime, version string) *UnsupportedVersionInput { + rules, ok := uc[runtime] + if !ok { + return nil + } + for i := range rules { + if matchUnsupportedPrefix(rules[i].Version, version) { + return &rules[i] + } + } + return nil +} + +// matchUnsupportedPrefix reports whether candidate starts with prefix and the +// next character (if any) is not a digit, preventing "16" from matching "160.0.1". +func matchUnsupportedPrefix(prefix, candidate string) bool { + if prefix == "" { + return false + } + if candidate == prefix { + return true + } + if len(candidate) > len(prefix) && candidate[:len(prefix)] == prefix { + next := candidate[len(prefix)] + return next < '0' || next > '9' + } + return false +} diff --git a/internal/config/unsupported_test.go b/internal/config/unsupported_test.go new file mode 100644 index 0000000..4ad6f68 --- /dev/null +++ b/internal/config/unsupported_test.go @@ -0,0 +1,195 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadUnsupportedConfig_EmptyPath(t *testing.T) { + cfg, err := LoadUnsupportedConfig("") + if err != nil { + t.Fatalf("expected no error for empty path, got %v", err) + } + if len(cfg) != 0 { + t.Fatalf("expected empty config, got %v", cfg) + } +} + +func TestLoadUnsupportedConfig_YAML(t *testing.T) { + content := ` +nodejs: + - version: "16" + reason: "EOL since 2023-09-11" + eol_date: "2023-09-11" +python: + - version: "3.8" + eol_date: "2024-10-07" +` + f := writeTempFile(t, "unsupported-*.yaml", content) + cfg, err := LoadUnsupportedConfig(f) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg["nodejs"]) != 1 { + t.Fatalf("expected 1 nodejs rule, got %d", len(cfg["nodejs"])) + } + if cfg["nodejs"][0].Version != "16" { + t.Errorf("expected version 16, got %q", cfg["nodejs"][0].Version) + } + if cfg["nodejs"][0].Reason != "EOL since 2023-09-11" { + t.Errorf("unexpected reason: %q", cfg["nodejs"][0].Reason) + } + if cfg["nodejs"][0].EOLDate != "2023-09-11" { + t.Errorf("unexpected eol_date: %q", cfg["nodejs"][0].EOLDate) + } + if len(cfg["python"]) != 1 || cfg["python"][0].Version != "3.8" { + t.Errorf("unexpected python rules: %v", cfg["python"]) + } +} + +func TestLoadUnsupportedConfig_JSON(t *testing.T) { + content := `{"nodejs":[{"version":"18","reason":"EOL","eol_date":"2025-04-30"}]}` + f := writeTempFile(t, "unsupported-*.json", content) + cfg, err := LoadUnsupportedConfig(f) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg["nodejs"]) != 1 || cfg["nodejs"][0].Version != "18" { + t.Errorf("unexpected nodejs rules: %v", cfg["nodejs"]) + } +} + +func TestLoadUnsupportedConfig_FileNotFound(t *testing.T) { + _, err := LoadUnsupportedConfig("/nonexistent/path/unsupported.yaml") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestLoadUnsupportedConfig_InvalidYAML(t *testing.T) { + // Unclosed flow sequence is a genuine YAML parse error. + f := writeTempFile(t, "unsupported-*.yaml", "nodejs: [unclosed") + _, err := LoadUnsupportedConfig(f) + if err == nil { + t.Fatal("expected error for invalid YAML, got nil") + } +} + +func TestLoadUnsupportedConfig_InvalidJSON(t *testing.T) { + f := writeTempFile(t, "unsupported-*.json", "{bad json") + _, err := LoadUnsupportedConfig(f) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestUnsupportedConfig_IsVersionUnsupported(t *testing.T) { + uc := UnsupportedConfig{ + "nodejs": { + {Version: "16", Reason: "EOL", EOLDate: "2023-09-11"}, + {Version: "14", EOLDate: "2023-04-30"}, + }, + "python": { + {Version: "3.8", EOLDate: "2024-10-07"}, + }, + } + + tests := []struct { + runtime string + version string + want bool + }{ + {"nodejs", "16.20.2", true}, + {"nodejs", "16.0.0", true}, + {"nodejs", "16", true}, // exact match + {"nodejs", "160.0.1", false}, // false positive guard + {"nodejs", "14.21.3", true}, + {"nodejs", "18.20.0", false}, // not in rules + {"python", "3.8.12", true}, + {"python", "3.80.0", false}, // false positive guard + {"python", "3.9.0", false}, + {"tomcat", "10.1.0", false}, // unknown runtime + {"nodejs", "", false}, // empty version + } + + for _, tt := range tests { + got := uc.IsVersionUnsupported(tt.runtime, tt.version) + if got != tt.want { + t.Errorf("IsVersionUnsupported(%q, %q) = %v, want %v", tt.runtime, tt.version, got, tt.want) + } + } +} + +func TestUnsupportedConfig_FindMatchingRule(t *testing.T) { + uc := UnsupportedConfig{ + "nodejs": { + {Version: "16", Reason: "EOL", EOLDate: "2023-09-11"}, + }, + } + + rule := uc.FindMatchingRule("nodejs", "16.20.2") + if rule == nil { + t.Fatal("expected rule to match 16.20.2, got nil") + } + if rule.EOLDate != "2023-09-11" { + t.Errorf("unexpected EOLDate: %q", rule.EOLDate) + } + + if uc.FindMatchingRule("nodejs", "18.0.0") != nil { + t.Error("expected nil for non-matching version 18.0.0") + } + if uc.FindMatchingRule("python", "3.8.1") != nil { + t.Error("expected nil for unknown runtime python") + } +} + +func TestUnsupportedConfig_EmptyConfig(t *testing.T) { + var uc UnsupportedConfig + if uc.IsVersionUnsupported("nodejs", "16.20.2") { + t.Error("nil config should never match") + } + if uc.FindMatchingRule("nodejs", "16.20.2") != nil { + t.Error("nil config FindMatchingRule should return nil") + } +} + +func TestGlobalConfig_UnsupportedFileField(t *testing.T) { + yml := ` +version: "1.0" +config: + unsupported_file: policies/unsupported-versions.yaml + storage: + database_path: ./downloads.db +runtimes: + nodejs: + enabled: false + endoflife_product: nodejs + policy_file: policies/nodejs-policy.json +` + f := writeTempFile(t, "registry-*.yaml", yml) + cfg, err := LoadConfig(f) + if err != nil { + t.Fatalf("unexpected error loading config: %v", err) + } + if cfg.Config.UnsupportedFile != "policies/unsupported-versions.yaml" { + t.Errorf("expected unsupported_file to be set, got %q", cfg.Config.UnsupportedFile) + } +} + +// writeTempFile creates a temporary file with the given content and returns its path. +// The file is removed when the test completes. +func writeTempFile(t *testing.T, pattern, content string) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), pattern) + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + if _, err := f.WriteString(content); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("failed to close temp file: %v", err) + } + return filepath.Clean(f.Name()) +} diff --git a/internal/runtimes/intellij/intellij.go b/internal/runtimes/intellij/intellij.go index 2f46c18..acf80ad 100644 --- a/internal/runtimes/intellij/intellij.go +++ b/internal/runtimes/intellij/intellij.go @@ -35,16 +35,22 @@ func NewAdapterWithConfig(cfg *config.Runtime, globalCfg *config.GlobalConfig, s } // setExpectedSHA256 remains for package-level tests. +// +//nolint:unused func (a *Adapter) setExpectedSHA256(classifier, hash string) { a.SetExpectedSHA256(classifier, hash) } // expectedSHA256 remains for package-level tests. +// +//nolint:unused func (a *Adapter) expectedSHA256(classifier string) string { return a.ExpectedSHA256(classifier) } // parseChecksumLine remains for package-level tests. +// +//nolint:unused func parseChecksumLine(body string) (string, error) { return jetbrains.ParseChecksumLine(body) } diff --git a/internal/sitegen/generator.go b/internal/sitegen/generator.go index 3743569..f5145d9 100644 --- a/internal/sitegen/generator.go +++ b/internal/sitegen/generator.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "github.com/clean-dependency-project/cdprun/internal/config" "log/slog" ) @@ -25,8 +26,9 @@ func NewGenerator(reader ReleaseReader, logger *slog.Logger) *Generator { // GenerateOptions contains options for site generation. type GenerateOptions struct { - OutputDir string - DryRun bool + OutputDir string + DryRun bool + UnsupportedVersions config.UnsupportedConfig } // Generate generates the complete static site from the database. @@ -76,7 +78,7 @@ func (g *Generator) Generate(ctx context.Context, opts GenerateOptions) error { // Render PEP 503 Simple index if !opts.DryRun { - if err := RenderSimpleIndex(model, opts.OutputDir, g.logger); err != nil { + if err := RenderSimpleIndex(model, opts.OutputDir, opts.UnsupportedVersions, g.logger); err != nil { return fmt.Errorf("failed to render simple index: %w", err) } g.logger.Info("rendered PEP 503 Simple index") @@ -85,4 +87,3 @@ func (g *Generator) Generate(ctx context.Context, opts GenerateOptions) error { g.logger.Info("site generation completed successfully") return nil } - diff --git a/internal/sitegen/simple.go b/internal/sitegen/simple.go index 1c1dbad..34db50d 100644 --- a/internal/sitegen/simple.go +++ b/internal/sitegen/simple.go @@ -9,6 +9,9 @@ import ( "sort" "strings" + "github.com/clean-dependency-project/cdprun/internal/config" + "github.com/clean-dependency-project/cdprun/internal/endoflife" + "github.com/clean-dependency-project/cdprun/internal/storage" "log/slog" ) @@ -60,11 +63,11 @@ type SimpleVersionIndex map[string][]SimpleArtifactEntry type SimpleRootIndex map[string][]SimpleArtifactEntry // RenderSimpleIndex generates hierarchical Simple index pages. -func RenderSimpleIndex(model *SiteModel, outDir string, logger *slog.Logger) error { +func RenderSimpleIndex(model *SiteModel, outDir string, unsupported config.UnsupportedConfig, logger *slog.Logger) error { simpleDir := filepath.Join(outDir, "simple") // Render /simple/index.html (list of runtimes) - if err := renderSimpleRootIndex(model, simpleDir, logger); err != nil { + if err := renderSimpleRootIndex(model, unsupported, simpleDir, logger); err != nil { return fmt.Errorf("failed to render simple root index: %w", err) } @@ -73,7 +76,7 @@ func RenderSimpleIndex(model *SiteModel, outDir string, logger *slog.Logger) err // Render pages for each runtime for _, runtime := range model.Runtimes { - if err := renderSimpleRuntimePages(runtime, scriptsIndex, simpleDir, logger); err != nil { + if err := renderSimpleRuntimePages(runtime, scriptsIndex, unsupported, simpleDir, logger); err != nil { return fmt.Errorf("failed to render pages for %s: %w", runtime.Name, err) } } @@ -83,7 +86,7 @@ func RenderSimpleIndex(model *SiteModel, outDir string, logger *slog.Logger) err // renderSimpleRuntimePages renders /simple//index.html and version pages. // scriptsIndex is pre-collected across all runtimes so every runtime index includes it. -func renderSimpleRuntimePages(runtime RuntimeModel, scriptsIndex []ScriptsArtifactEntry, simpleDir string, logger *slog.Logger) error { +func renderSimpleRuntimePages(runtime RuntimeModel, scriptsIndex []ScriptsArtifactEntry, unsupported config.UnsupportedConfig, simpleDir string, logger *slog.Logger) error { runtimeDir := filepath.Join(simpleDir, runtime.Name) // Collect unique major versions @@ -98,11 +101,12 @@ func renderSimpleRuntimePages(runtime RuntimeModel, scriptsIndex []ScriptsArtifa // Every runtime gets a "scripts" key so consumers can discover scripts.zip // regardless of which runtime index they query. runtimeIndex := collectRuntimeArtifactIndex(runtime) - out := make(map[string]any, len(runtimeIndex)+1) + out := make(map[string]any, len(runtimeIndex)+2) for k, v := range runtimeIndex { out[k] = v } out["scripts"] = scriptsIndex + out["unsupported"] = expandUnsupportedVersions(runtime, unsupported) jsonData, err := json.MarshalIndent(out, "", " ") if err != nil { @@ -116,7 +120,7 @@ func renderSimpleRuntimePages(runtime RuntimeModel, scriptsIndex []ScriptsArtifa // Render version pages for each major version for _, major := range majorVersions { - if err := renderVersionPage(runtime, major, runtimeDir, logger); err != nil { + if err := renderVersionPage(runtime, major, unsupported, runtimeDir, logger); err != nil { return err } } @@ -196,7 +200,7 @@ func renderRuntimeIndex(runtimeName string, majors []int, runtimeDir string, log buf.WriteString("\n\n") for _, major := range majors { - buf.WriteString(fmt.Sprintf("v%d
\n", major, major)) + fmt.Fprintf(&buf, "v%d
\n", major, major) } buf.WriteString("\n\n\n") @@ -211,7 +215,7 @@ func renderRuntimeIndex(runtimeName string, majors []int, runtimeDir string, log } // renderVersionPage renders /simple//v/index.html with all binaries. -func renderVersionPage(runtime RuntimeModel, major int, runtimeDir string, logger *slog.Logger) error { +func renderVersionPage(runtime RuntimeModel, major int, unsupported config.UnsupportedConfig, runtimeDir string, logger *slog.Logger) error { // Collect all distributions for this major version distMap := make(map[string]DistributionModel) @@ -236,9 +240,9 @@ func renderVersionPage(runtime RuntimeModel, major int, runtimeDir string, logge // Render HTML var buf bytes.Buffer buf.WriteString("\n\n") - buf.WriteString(fmt.Sprintf("%s v%d", runtime.Name, major)) + fmt.Fprintf(&buf, "%s v%d", runtime.Name, major) buf.WriteString("\n\n

") - buf.WriteString(fmt.Sprintf("%s v%d binaries", runtime.Name, major)) + fmt.Fprintf(&buf, "%s v%d binaries", runtime.Name, major) buf.WriteString("

\n\n") for _, dist := range distributions { @@ -262,9 +266,24 @@ func renderVersionPage(runtime RuntimeModel, major int, runtimeDir string, logge return fmt.Errorf("failed to write version page: %w", err) } - // Also render JSON index for automation tooling (e.g., Nexus proxy discovery) + // Render JSON index: artifact entries + unsupported list filtered to this major. artifactIndex := collectArtifactIndexByMajor(runtime, major) - jsonData, err := json.MarshalIndent(artifactIndex, "", " ") + allUnsupported := expandUnsupportedVersions(runtime, unsupported) + var majorUnsupported []endoflife.PolicyVersion + for _, pv := range allUnsupported { + parsed, _, _, err := storage.ParseSemver(pv.Version) + if err == nil && parsed == major { + majorUnsupported = append(majorUnsupported, pv) + } + } + + versionOut := make(map[string]any, len(artifactIndex)+1) + for k, v := range artifactIndex { + versionOut[k] = v + } + versionOut["unsupported"] = majorUnsupported + + jsonData, err := json.MarshalIndent(versionOut, "", " ") if err != nil { return fmt.Errorf("failed to serialize artifact index: %w", err) } @@ -475,7 +494,7 @@ func collectDistributionsFromVersion(version VersionModel, distMap map[string]Di } // renderSimpleRootIndex renders /simple/index.html listing all runtimes. -func renderSimpleRootIndex(model *SiteModel, simpleDir string, logger *slog.Logger) error { +func renderSimpleRootIndex(model *SiteModel, unsupported config.UnsupportedConfig, simpleDir string, logger *slog.Logger) error { // Extract runtime names runtimeNames := make([]string, 0, len(model.Runtimes)) for _, runtime := range model.Runtimes { @@ -488,7 +507,7 @@ func renderSimpleRootIndex(model *SiteModel, simpleDir string, logger *slog.Logg buf.WriteString("\n\nSimple Index\n\n

Available Runtimes

\n\n") for _, name := range runtimeNames { - buf.WriteString(fmt.Sprintf("%s
\n", name, name)) + fmt.Fprintf(&buf, "%s
\n", name, name) } buf.WriteString("\n\n\n") @@ -498,9 +517,20 @@ func renderSimpleRootIndex(model *SiteModel, simpleDir string, logger *slog.Logg return fmt.Errorf("failed to write simple root index: %w", err) } - // Also render consolidated JSON index with all artifact paths across all runtimes + // Render consolidated JSON index: all artifact paths keyed by OS, plus + // an "unsupported" key mapping runtime name → []PolicyVersion. allIndex := collectAllArtifactIndex(model) - jsonData, err := json.MarshalIndent(allIndex, "", " ") + out := make(map[string]any, len(allIndex)+1) + for k, v := range allIndex { + out[k] = v + } + unsupportedByRuntime := make(map[string][]endoflife.PolicyVersion, len(model.Runtimes)) + for _, rt := range model.Runtimes { + unsupportedByRuntime[rt.Name] = expandUnsupportedVersions(rt, unsupported) + } + out["unsupported"] = unsupportedByRuntime + + jsonData, err := json.MarshalIndent(out, "", " ") if err != nil { return fmt.Errorf("failed to serialize consolidated artifact index: %w", err) } @@ -579,3 +609,58 @@ func collectAllArtifactIndex(model *SiteModel) SimpleRootIndex { return index } + +// expandUnsupportedVersions builds the list of concrete unsupported versions for a runtime +// by walking every version present in the model and prefix-matching against unsupported rules. +// Duplicate concrete versions across platforms are deduplicated before matching. +func expandUnsupportedVersions(rt RuntimeModel, uc config.UnsupportedConfig) []endoflife.PolicyVersion { + if len(uc) == 0 { + return nil + } + + seen := make(map[string]struct{}) + var result []endoflife.PolicyVersion + + for _, platform := range rt.Platforms { + for _, version := range platform.Versions { + v := version.Version + if _, already := seen[v]; already { + continue + } + seen[v] = struct{}{} + + rule := uc.FindMatchingRule(rt.Name, v) + if rule == nil { + continue + } + + pv := endoflife.PolicyVersion{ + Version: v, + Supported: false, + } + if rule.EOLDate != "" { + pv.EOL = rule.EOLDate + } + if rule.Reason != "" { + pv.Notes = rule.Reason + } + result = append(result, pv) + } + } + + sort.Slice(result, func(i, j int) bool { + iMaj, iMin, iPat, iErr := storage.ParseSemver(result[i].Version) + jMaj, jMin, jPat, jErr := storage.ParseSemver(result[j].Version) + if iErr != nil || jErr != nil { + return result[i].Version < result[j].Version + } + if iMaj != jMaj { + return iMaj < jMaj + } + if iMin != jMin { + return iMin < jMin + } + return iPat < jPat + }) + return result +} diff --git a/internal/sitegen/sitegen_test.go b/internal/sitegen/sitegen_test.go index 0d9aa45..ad0a92a 100644 --- a/internal/sitegen/sitegen_test.go +++ b/internal/sitegen/sitegen_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/clean-dependency-project/cdprun/internal/config" + "github.com/clean-dependency-project/cdprun/internal/endoflife" "github.com/clean-dependency-project/cdprun/internal/storage" ) @@ -884,7 +886,7 @@ func TestRenderSimpleIndex(t *testing.T) { }, } - err := RenderSimpleIndex(model, tempDir, logger) + err := RenderSimpleIndex(model, tempDir, config.UnsupportedConfig{}, logger) if err != nil { t.Fatalf("RenderSimpleIndex() error = %v", err) } @@ -962,19 +964,29 @@ func TestRenderSimpleIndex(t *testing.T) { t.Fatalf("Expected %s to exist: %v", rootJSONPath, err) } - var rootIndex SimpleRootIndex - if err := json.Unmarshal(rootContent, &rootIndex); err != nil { + var rootRaw map[string]json.RawMessage + if err := json.Unmarshal(rootContent, &rootRaw); err != nil { t.Fatalf("Failed to parse consolidated JSON index: %v", err) } - if len(rootIndex["linux"]) != 1 { - t.Fatalf("Consolidated JSON index for linux length = %d, want 1", len(rootIndex["linux"])) + var linuxEntries []SimpleArtifactEntry + if err := json.Unmarshal(rootRaw["linux"], &linuxEntries); err != nil { + t.Fatalf("Failed to parse linux entries in root JSON: %v", err) } - if rootIndex["linux"][0].Binary != expected { - t.Errorf("Consolidated JSON entry = %q, want %q", rootIndex["linux"][0].Binary, expected) + + if len(linuxEntries) != 1 { + t.Fatalf("Consolidated JSON index for linux length = %d, want 1", len(linuxEntries)) + } + if linuxEntries[0].Binary != expected { + t.Errorf("Consolidated JSON entry = %q, want %q", linuxEntries[0].Binary, expected) } - if rootIndex["linux"][0].Version != "22.15.0" { - t.Errorf("Consolidated JSON artifact version = %q, want %q", rootIndex["linux"][0].Version, "22.15.0") + if linuxEntries[0].Version != "22.15.0" { + t.Errorf("Consolidated JSON artifact version = %q, want %q", linuxEntries[0].Version, "22.15.0") + } + + // Verify "unsupported" key is present (may be empty for a test with no unsupported config). + if _, ok := rootRaw["unsupported"]; !ok { + t.Error("Consolidated root JSON index missing 'unsupported' key") } } @@ -1017,7 +1029,7 @@ func TestRenderSimpleIndex_YarnRuntime(t *testing.T) { }, } - if err := RenderSimpleIndex(model, tempDir, logger); err != nil { + if err := RenderSimpleIndex(model, tempDir, config.UnsupportedConfig{}, logger); err != nil { t.Fatalf("RenderSimpleIndex() error = %v", err) } @@ -1339,7 +1351,7 @@ func TestRenderSimpleIndex_EmptyModel(t *testing.T) { Runtimes: []RuntimeModel{}, } - err := RenderSimpleIndex(model, tempDir, logger) + err := RenderSimpleIndex(model, tempDir, config.UnsupportedConfig{}, logger) if err != nil { t.Fatalf("RenderSimpleIndex() with empty model error = %v", err) } @@ -1349,19 +1361,344 @@ func TestRenderSimpleIndex_EmptyModel(t *testing.T) { t.Error("Expected simple/index.html to exist even with empty model") } - // Verify consolidated JSON index exists and is empty array + // Verify consolidated JSON index exists and has the "unsupported" key rootJSONPath := filepath.Join(tempDir, "simple", "index.json") rootContent, err := os.ReadFile(rootJSONPath) if err != nil { t.Fatalf("Expected %s to exist: %v", rootJSONPath, err) } - var rootIndex SimpleRootIndex - if err := json.Unmarshal(rootContent, &rootIndex); err != nil { + var rootRaw map[string]json.RawMessage + if err := json.Unmarshal(rootContent, &rootRaw); err != nil { t.Fatalf("Failed to parse consolidated JSON index: %v", err) } - if len(rootIndex) != 0 { - t.Errorf("Consolidated JSON index OS count = %d, want 0 for empty model", len(rootIndex)) + // Only the "unsupported" meta-key should be present; no OS keys for an empty model. + if _, ok := rootRaw["unsupported"]; !ok { + t.Error("Consolidated root JSON index missing 'unsupported' key") + } + // Remove the meta-key and confirm no OS entries remain. + delete(rootRaw, "unsupported") + if len(rootRaw) != 0 { + t.Errorf("Consolidated JSON index OS count = %d, want 0 for empty model", len(rootRaw)) + } +} + +// TestRenderSimpleIndex_UnsupportedVersions verifies that the "unsupported" key is +// correctly populated at all three JSON output levels (root, runtime, major-version) +// when an UnsupportedConfig with matching rules is provided. +func TestRenderSimpleIndex_UnsupportedVersions(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + tempDir := t.TempDir() + + // Model: nodejs v22 (supported) and v16 (should be flagged unsupported). + model := &SiteModel{ + Runtimes: []RuntimeModel{ + { + Name: "nodejs", + Platforms: []PlatformModel{ + { + OS: "linux", + Versions: []VersionModel{ + { + Major: 22, + Minor: 15, + Patch: 0, + Version: "22.15.0", + Releases: []ReleaseModel{ + { + ReleaseTag: "nodejs-22.15.0", + Artifacts: []ArtifactModel{ + { + Binary: &FileModel{ + Filename: "node-v22.15.0-linux-x64.tar.gz", + URL: "https://example.com/22.tar.gz", + SHA256: "abc123", + }, + }, + }, + }, + }, + }, + { + Major: 16, + Minor: 20, + Patch: 2, + Version: "16.20.2", + Releases: []ReleaseModel{ + { + ReleaseTag: "nodejs-16.20.2", + Artifacts: []ArtifactModel{ + { + Binary: &FileModel{ + Filename: "node-v16.20.2-linux-x64.tar.gz", + URL: "https://example.com/16.tar.gz", + SHA256: "def456", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Mark nodejs major version 16 as unsupported. + unsupportedCfg := config.UnsupportedConfig{ + "nodejs": { + {Version: "16", Reason: "EOL since 2023-09-11", EOLDate: "2023-09-11"}, + }, + } + + if err := RenderSimpleIndex(model, tempDir, unsupportedCfg, logger); err != nil { + t.Fatalf("RenderSimpleIndex() error = %v", err) + } + + // ── 1. Root simple/index.json ────────────────────────────────────────────── + rootContent, err := os.ReadFile(filepath.Join(tempDir, "simple", "index.json")) + if err != nil { + t.Fatalf("simple/index.json: %v", err) + } + + var rootRaw map[string]json.RawMessage + if err := json.Unmarshal(rootContent, &rootRaw); err != nil { + t.Fatalf("parse simple/index.json: %v", err) + } + + unsupportedRootRaw, ok := rootRaw["unsupported"] + if !ok { + t.Fatal("simple/index.json missing 'unsupported' key") + } + + var unsupportedRoot map[string][]endoflife.PolicyVersion + if err := json.Unmarshal(unsupportedRootRaw, &unsupportedRoot); err != nil { + t.Fatalf("parse root unsupported map: %v", err) + } + + nodejsUnsupported, ok := unsupportedRoot["nodejs"] + if !ok { + t.Fatal("root unsupported missing 'nodejs' key") + } + if len(nodejsUnsupported) != 1 { + t.Fatalf("root unsupported nodejs count = %d, want 1", len(nodejsUnsupported)) + } + if nodejsUnsupported[0].Version != "16.20.2" { + t.Errorf("root unsupported nodejs[0].version = %q, want %q", nodejsUnsupported[0].Version, "16.20.2") + } + if nodejsUnsupported[0].Supported { + t.Error("root unsupported nodejs[0].supported should be false") + } + if nodejsUnsupported[0].EOL != "2023-09-11" { + t.Errorf("root unsupported nodejs[0].eol = %q, want %q", nodejsUnsupported[0].EOL, "2023-09-11") + } + + // ── 2. Runtime simple/nodejs/index.json ─────────────────────────────────── + rtContent, err := os.ReadFile(filepath.Join(tempDir, "simple", "nodejs", "index.json")) + if err != nil { + t.Fatalf("simple/nodejs/index.json: %v", err) + } + + var rtRaw map[string]json.RawMessage + if err := json.Unmarshal(rtContent, &rtRaw); err != nil { + t.Fatalf("parse simple/nodejs/index.json: %v", err) + } + + unsupportedRtRaw, ok := rtRaw["unsupported"] + if !ok { + t.Fatal("simple/nodejs/index.json missing 'unsupported' key") + } + + var unsupportedRt []endoflife.PolicyVersion + if err := json.Unmarshal(unsupportedRtRaw, &unsupportedRt); err != nil { + t.Fatalf("parse runtime unsupported list: %v", err) + } + + if len(unsupportedRt) != 1 { + t.Fatalf("runtime unsupported count = %d, want 1", len(unsupportedRt)) + } + if unsupportedRt[0].Version != "16.20.2" { + t.Errorf("runtime unsupported[0].version = %q, want %q", unsupportedRt[0].Version, "16.20.2") + } + + // ── 3. Major-version simple/nodejs/v16/index.json ───────────────────────── + v16Content, err := os.ReadFile(filepath.Join(tempDir, "simple", "nodejs", "v16", "index.json")) + if err != nil { + t.Fatalf("simple/nodejs/v16/index.json: %v", err) + } + + var v16Raw map[string]json.RawMessage + if err := json.Unmarshal(v16Content, &v16Raw); err != nil { + t.Fatalf("parse simple/nodejs/v16/index.json: %v", err) + } + + unsupportedV16Raw, ok := v16Raw["unsupported"] + if !ok { + t.Fatal("simple/nodejs/v16/index.json missing 'unsupported' key") + } + + var unsupportedV16 []endoflife.PolicyVersion + if err := json.Unmarshal(unsupportedV16Raw, &unsupportedV16); err != nil { + t.Fatalf("parse v16 unsupported list: %v", err) + } + + if len(unsupportedV16) != 1 { + t.Fatalf("v16 unsupported count = %d, want 1", len(unsupportedV16)) + } + if unsupportedV16[0].Version != "16.20.2" { + t.Errorf("v16 unsupported[0].version = %q, want %q", unsupportedV16[0].Version, "16.20.2") + } + + // ── 4. Supported version (v22) must NOT appear in its major-version unsupported list ─ + v22Content, err := os.ReadFile(filepath.Join(tempDir, "simple", "nodejs", "v22", "index.json")) + if err != nil { + t.Fatalf("simple/nodejs/v22/index.json: %v", err) + } + + var v22Raw map[string]json.RawMessage + if err := json.Unmarshal(v22Content, &v22Raw); err != nil { + t.Fatalf("parse simple/nodejs/v22/index.json: %v", err) + } + + unsupportedV22Raw, ok := v22Raw["unsupported"] + if !ok { + t.Fatal("simple/nodejs/v22/index.json missing 'unsupported' key") + } + + var unsupportedV22 []endoflife.PolicyVersion + if err := json.Unmarshal(unsupportedV22Raw, &unsupportedV22); err != nil { + t.Fatalf("parse v22 unsupported list: %v", err) + } + + if len(unsupportedV22) != 0 { + t.Errorf("v22 unsupported count = %d, want 0 (v22 is supported)", len(unsupportedV22)) + } +} + +// TestExpandUnsupportedVersions tests the expandUnsupportedVersions helper directly. +func TestExpandUnsupportedVersions(t *testing.T) { + rt := RuntimeModel{ + Name: "nodejs", + Platforms: []PlatformModel{ + { + OS: "linux", + Versions: []VersionModel{ + {Version: "16.20.2", Major: 16}, + {Version: "16.20.1", Major: 16}, + {Version: "18.20.0", Major: 18}, + {Version: "22.15.0", Major: 22}, + }, + }, + // Same versions on a second platform — must not produce duplicates. + { + OS: "windows", + Versions: []VersionModel{ + {Version: "16.20.2", Major: 16}, + {Version: "18.20.0", Major: 18}, + }, + }, + }, + } + + tests := []struct { + name string + uc config.UnsupportedConfig + wantVersions []string + wantNoDuplicate bool + }{ + { + name: "empty config returns nil", + uc: config.UnsupportedConfig{}, + wantVersions: nil, + }, + { + name: "prefix 16 expands to all 16.x.y versions without duplicates", + uc: config.UnsupportedConfig{ + "nodejs": {{Version: "16", Reason: "EOL", EOLDate: "2023-09-11"}}, + }, + wantVersions: []string{"16.20.1", "16.20.2"}, + wantNoDuplicate: true, + }, + { + name: "numeric sort: 8.x must come before 22.x (lexicographic would reverse this)", + uc: config.UnsupportedConfig{ + "nodejs": { + {Version: "16", Reason: "EOL", EOLDate: "2023-09-11"}, + {Version: "18", Reason: "EOL", EOLDate: "2025-04-30"}, + {Version: "22", Reason: "EOL"}, + }, + }, + // 16.20.1, 16.20.2, 18.20.0, 22.15.0 — numeric order, not lexicographic + wantVersions: []string{"16.20.1", "16.20.2", "18.20.0", "22.15.0"}, + }, + { + name: "exact version match", + uc: config.UnsupportedConfig{ + "nodejs": {{Version: "18.20.0", Reason: "EOL", EOLDate: "2024-04-30"}}, + }, + wantVersions: []string{"18.20.0"}, + }, + { + name: "prefix 16 must not match 160.x versions (false positive guard)", + uc: config.UnsupportedConfig{ + "nodejs": {{Version: "1", Reason: "ancient"}}, + }, + // "1" should NOT match "16.20.2", "18.20.0", or "22.15.0" + wantVersions: nil, + }, + { + name: "unknown runtime returns nil", + uc: config.UnsupportedConfig{ + "temurin": {{Version: "16", Reason: "EOL"}}, + }, + wantVersions: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := expandUnsupportedVersions(rt, tc.uc) + + if len(tc.wantVersions) == 0 { + if len(got) != 0 { + t.Errorf("got %d entries, want 0: %v", len(got), got) + } + return + } + + gotVersions := make([]string, len(got)) + for i, pv := range got { + gotVersions[i] = pv.Version + } + + if len(gotVersions) != len(tc.wantVersions) { + t.Fatalf("got versions %v, want %v", gotVersions, tc.wantVersions) + } + for i, v := range tc.wantVersions { + if gotVersions[i] != v { + t.Errorf("got[%d] = %q, want %q", i, gotVersions[i], v) + } + } + + if tc.wantNoDuplicate { + seen := make(map[string]int) + for _, pv := range got { + seen[pv.Version]++ + if seen[pv.Version] > 1 { + t.Errorf("duplicate version %q in output", pv.Version) + } + } + } + + // Verify all returned entries have Supported=false. + for _, pv := range got { + if pv.Supported { + t.Errorf("version %q has Supported=true, want false", pv.Version) + } + } + }) } } diff --git a/policies/unsupported-versions.yaml b/policies/unsupported-versions.yaml new file mode 100644 index 0000000..25202a4 --- /dev/null +++ b/policies/unsupported-versions.yaml @@ -0,0 +1,139 @@ +# Unsupported runtime versions that should no longer be downloaded or distributed. +# +# Structure: +# runtime_name: +# - version: "" # prefix matching: "16" matches "16.x.y" but not "160.x.y" +# reason: "" +# eol_date: "" +# +# Sources: per-runtime policy files in policies/ (generated from endoflife.date) +# All entries here have supported=false in the corresponding policy JSON. +# +# Clients reading simple/.../index.json should check the "unsupported" key +# and remove or block versions listed there. + +nodejs: + # Node.js versions that have reached end-of-life per endoflife.date + # Versions 1-3 have no known date (predates formal EOL tracking). + - version: "1" + reason: "EOL (no formal date)" + - version: "2" + reason: "EOL (no formal date)" + - version: "3" + reason: "EOL (no formal date)" + - version: "4" + reason: "EOL since 2018-04-30 (LTS)" + eol_date: "2018-04-30" + - version: "5" + reason: "EOL since 2016-06-30 (non-LTS odd release)" + eol_date: "2016-06-30" + - version: "6" + reason: "EOL since 2019-04-30 (LTS)" + eol_date: "2019-04-30" + - version: "7" + reason: "EOL since 2017-06-30 (non-LTS odd release)" + eol_date: "2017-06-30" + - version: "8" + reason: "EOL since 2019-12-31 (LTS)" + eol_date: "2019-12-31" + - version: "9" + reason: "EOL since 2018-06-30 (non-LTS odd release)" + eol_date: "2018-06-30" + - version: "10" + reason: "EOL since 2021-04-30 (LTS)" + eol_date: "2021-04-30" + - version: "11" + reason: "EOL since 2019-06-30 (non-LTS odd release)" + eol_date: "2019-06-30" + - version: "12" + reason: "EOL since 2022-04-30 (LTS)" + eol_date: "2022-04-30" + - version: "13" + reason: "EOL since 2020-06-01 (non-LTS odd release)" + eol_date: "2020-06-01" + - version: "14" + reason: "EOL since 2023-04-30 (LTS)" + eol_date: "2023-04-30" + - version: "15" + reason: "EOL since 2021-06-01 (non-LTS odd release)" + eol_date: "2021-06-01" + - version: "16" + reason: "EOL since 2023-09-11 (LTS)" + eol_date: "2023-09-11" + - version: "17" + reason: "EOL since 2022-06-01 (non-LTS odd release)" + eol_date: "2022-06-01" + - version: "18" + reason: "EOL since 2025-04-30 (LTS)" + eol_date: "2025-04-30" + - version: "19" + reason: "EOL since 2023-06-01 (non-LTS odd release)" + eol_date: "2023-06-01" + - version: "21" + reason: "EOL since 2024-06-01 (non-LTS odd release)" + eol_date: "2024-06-01" + - version: "23" + reason: "EOL since 2025-06-01 (non-LTS odd release)" + eol_date: "2025-06-01" + +temurin: + # Eclipse Temurin (OpenJDK) versions that have reached end-of-life + - version: "8" + reason: "EOL since 2022-03-31" + eol_date: "2022-03-31" + - version: "22" + reason: "EOL since 2024-09-17 (non-LTS short-term release)" + eol_date: "2024-09-17" + - version: "23" + reason: "EOL since 2025-03-18 (non-LTS short-term release)" + eol_date: "2025-03-18" + - version: "24" + reason: "EOL since 2025-09-16 (non-LTS short-term release)" + eol_date: "2025-09-16" + +tomcat: + # Apache Tomcat versions that have reached end-of-life + - version: "8" + reason: "EOL since 2024-03-31" + eol_date: "2024-03-31" + +python: + # Python versions that have reached end-of-life per endoflife.date + # Note: python-policy.json only tracks actively-distributed versions (3.10+); + # this list is the authoritative EOL source for download filtering and JSON output. + - version: "2.6" + reason: "EOL since 2013-10-29" + eol_date: "2013-10-29" + - version: "2.7" + reason: "EOL since 2020-01-01" + eol_date: "2020-01-01" + - version: "3.0" + reason: "EOL since 2009-06-27" + eol_date: "2009-06-27" + - version: "3.1" + reason: "EOL since 2012-04-09" + eol_date: "2012-04-09" + - version: "3.2" + reason: "EOL since 2016-02-20" + eol_date: "2016-02-20" + - version: "3.3" + reason: "EOL since 2017-09-29" + eol_date: "2017-09-29" + - version: "3.4" + reason: "EOL since 2019-03-18" + eol_date: "2019-03-18" + - version: "3.5" + reason: "EOL since 2020-09-30" + eol_date: "2020-09-30" + - version: "3.6" + reason: "EOL since 2021-12-23" + eol_date: "2021-12-23" + - version: "3.7" + reason: "EOL since 2023-06-27" + eol_date: "2023-06-27" + - version: "3.8" + reason: "EOL since 2024-10-07" + eol_date: "2024-10-07" + - version: "3.9" + reason: "EOL since 2025-10-31" + eol_date: "2025-10-31" diff --git a/runtime-registry.yaml b/runtime-registry.yaml index 5c766c3..22c2ced 100644 --- a/runtime-registry.yaml +++ b/runtime-registry.yaml @@ -6,6 +6,7 @@ config: download_timeout: "300s" auto_download_all_platforms: true ignore_file: "policies/ignore-versions.yaml" + unsupported_file: "policies/unsupported-versions.yaml" storage: database_path: "./downloads.db" packaging_execution: