Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/user/reference/cli/azldev_component_build.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/user/reference/cli/azldev_component_diff-sources.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/user/reference/cli/azldev_component_list.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/user/reference/cli/azldev_component_query.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions docs/user/reference/cli/azldev_component_render.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions docs/user/reference/cli/azldev_component_update.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions internal/app/azldev/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ lives), or use -C to point to one.`,
app.cmd.SetCompletionCommandGroupID(CommandGroupMeta)
app.addAdvancedCommandHint()

app.registerGlobalFlags()

return app
}

// registerGlobalFlags defines all persistent (global) flags for the CLI.
func (app *App) registerGlobalFlags() {
// Define global flags and configuration settings.
app.cmd.PersistentFlags().BoolVarP(&app.verbose, "verbose", "v", false, "enable verbose output")
app.cmd.PersistentFlags().BoolVarP(&app.quiet, "quiet", "q", false, "only enable minimal output")
Expand All @@ -176,8 +183,6 @@ lives), or use -C to point to one.`,
"output colorization mode {always, auto, never}")
app.cmd.PersistentFlags().BoolVar(&app.permissiveConfigParsing, "permissive-config",
false, "do not fail on unknown fields in TOML config files")

return app
}

// addAdvancedCommandHint embeds a hint about the hidden "advanced" command group
Expand Down
4 changes: 4 additions & 0 deletions internal/app/azldev/cmds/component/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ Component name patterns support glob syntax (*, ?, []).`,
RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) {
options.ComponentFilter.ComponentNamePatterns = append(args, options.ComponentFilter.ComponentNamePatterns...)

// List is read-only — skip lock validation so users can always
// inspect their components even when locks are stale.
options.ComponentFilter.SkipLockValidation = true

Comment thread
dmcilvaney marked this conversation as resolved.
return ListComponentConfigs(env, options)
}),
ValidArgsFunction: components.GenerateComponentNameCompletions,
Expand Down
7 changes: 6 additions & 1 deletion internal/app/azldev/cmds/component/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ specs/v/vim).

Unlike prepare-sources, render skips downloading source tarballs from the
lookaside cache — only spec files, patches, scripts, and other git-tracked
sidecar files are included. Multiple components can be rendered at once.`,
sidecar files are included. Multiple components can be rendered at once.

When rendering all components (-a), the --clean-stale flag removes rendered
directories that no longer correspond to any current component. Stale cleanup
is skipped when rendering individual components to avoid accidentally removing
directories for components not included in the filter.`,
Example: ` # Render all components (output dir from config)
azldev component render -a

Expand Down
68 changes: 51 additions & 17 deletions internal/app/azldev/cmds/component/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ distro snapshot time or explicit pin, then records it in locks/<name>.lock.
Subsequent commands (render, build) use the locked commit for deterministic,
reproducible results.

Local components are skipped — they have no upstream commit to resolve.`,
Local components are skipped — they have no upstream commit to resolve.

When updating all components (-a), orphan lock files (locks for components
that no longer exist in the project config) are automatically pruned.
Orphan pruning is skipped when updating individual components to avoid
accidentally removing lock files for components not included in the filter.`,
Example: ` # Update all components
azldev component update -a

Expand All @@ -51,6 +56,9 @@ Local components are skipped — they have no upstream commit to resolve.`,
# Update components in a group
azldev component update -g core`,
RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) {
// Skip lock validation — update is the lock file writer.
options.ComponentFilter.SkipLockValidation = true

Comment thread
dmcilvaney marked this conversation as resolved.
options.ComponentFilter.ComponentNamePatterns = append(
args, options.ComponentFilter.ComponentNamePatterns...,
)
Expand Down Expand Up @@ -87,20 +95,25 @@ func UpdateComponents(env *azldev.Env, options *UpdateComponentOptions) ([]Updat
}

comps := resolved.Components()
if len(comps) == 0 {
if len(comps) == 0 && !options.ComponentFilter.IncludeAllComponents {
return nil, errors.New("no components matched the filter")
}

// Resolve upstream commits in parallel.
// Resolve upstream commits in parallel (no-op for empty list).
store := env.LockStore()
if store == nil {
return nil, errors.New("no project directory configured; cannot update lock files")
}

results := resolveUpstreamCommitsParallel(env, comps, store)

// Check results and bail on errors/cancellation before saving.
if err := checkUpdateResults(env, results); err != nil {
// Don't save if the context was cancelled (Ctrl+C).
if env.Context().Err() != nil {
return results, errors.New("update cancelled; lock files not updated")
}

Comment thread
dmcilvaney marked this conversation as resolved.
// Check results and bail on errors before saving.
if err := checkUpdateResults(results); err != nil {
return results, err
}

Expand All @@ -109,8 +122,33 @@ func UpdateComponents(env *azldev.Env, options *UpdateComponentOptions) ([]Updat
return results, err
}

// Filter results for table output: only show changed components.
return filterChangedResults(results), nil
// Prune orphan lock files when updating all components.
// Use the resolved component set (not raw config) to include
// spec-glob-discovered components that aren't in config directly.
// Lock files are version controlled, so pruning is safe even if the
// resolved set is empty (e.g., all components removed from config).
if options.ComponentFilter.IncludeAllComponents {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still a bit uncomfortable with commands whose semantics are different when used with -a vs. not. It violates the principle of least surprise.

Can we at minimum indicate it in the command help usage?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added here and to render

if len(comps) == 0 {
slog.Warn("No components resolved; all existing lock files will be treated as orphans")
}

resolvedNames := make(map[string]projectconfig.ComponentConfig, len(comps))
for _, comp := range comps {
resolvedNames[comp.GetName()] = *comp.GetConfig()
}

pruned, pruneErr := store.PruneOrphans(resolvedNames)
Comment thread
dmcilvaney marked this conversation as resolved.
if pruneErr != nil {
return results, fmt.Errorf("pruning orphan lock files:\n%w", pruneErr)
}
Comment thread
dmcilvaney marked this conversation as resolved.

if pruned > 0 {
slog.Info("Pruned orphan lock files", "count", pruned)
}
}

// Filter results for table output: show changed and skipped components.
return filterDisplayResults(results), nil
}

// saveComponentLocks writes a lock file for each changed component.
Expand Down Expand Up @@ -144,8 +182,8 @@ func saveComponentLocks(store *lockfile.Store, results []UpdateResult) error {
}

// checkUpdateResults counts results, logs a summary, and returns an error if any
// component failed or the context was cancelled.
func checkUpdateResults(env *azldev.Env, results []UpdateResult) error {
// component failed.
func checkUpdateResults(results []UpdateResult) error {
var changed, skipped int

var failedNames []string
Expand All @@ -171,10 +209,6 @@ func checkUpdateResults(env *azldev.Env, results []UpdateResult) error {
len(failedNames), strings.Join(failedNames, "\n "))
}

if env.Context().Err() != nil {
return errors.New("update cancelled; lock files not updated")
}

slog.Info("Update complete",
"total", len(results),
"changed", changed,
Expand All @@ -183,12 +217,12 @@ func checkUpdateResults(env *azldev.Env, results []UpdateResult) error {
return nil
}

// filterChangedResults returns only changed results for table display.
func filterChangedResults(results []UpdateResult) []UpdateResult {
// filterDisplayResults returns changed and skipped results for table display.
func filterDisplayResults(results []UpdateResult) []UpdateResult {
var tableResults []UpdateResult

for idx := range results {
if results[idx].Changed {
if results[idx].Changed || results[idx].Skipped {
tableResults = append(tableResults, results[idx])
}
}
Expand Down Expand Up @@ -263,7 +297,7 @@ func resolveUpstreamCommitsParallel(

// checkLockChanged compares the resolved commit against the existing lock file
// to determine if the component changed. Distinguishes "not found" (new
// component) from real errors (corrupt lock file).
// component) from real errors (corrupt/unreadable lock file).
func checkLockChanged(store *lockfile.Store, componentName string, result *UpdateResult) {
exists, existsErr := store.Exists(componentName)
if existsErr != nil {
Expand Down
12 changes: 12 additions & 0 deletions internal/app/azldev/core/components/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package components

import (
"os"
"strings"

"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
Expand All @@ -22,6 +23,13 @@ type ComponentFilter struct {
SpecPaths []string
// If true, then *all* known components are included in the result set.
IncludeAllComponents bool
// SkipLockValidation disables lock file consistency checks for this
// filter's resolution. Commands that write lock files (update) or are
// read-only (list) set this to true. The '--skip-lock-validation' flag
// defaults based on AZLDEV_ENABLE_LOCK_VALIDATION during rollout.
//nolint:godox // tracked by TODO(lockfiles) tag.
// TODO(lockfiles): remove env var gate; default to false (validation on).
SkipLockValidation bool
}

// HasNoCriteria returns true if the filter has no criteria set, meaning that it will never
Expand All @@ -48,6 +56,10 @@ func AddComponentFilterOptionsToCommand(cmd *cobra.Command, filter *ComponentFil

cmd.Flags().StringArrayVarP(&filter.SpecPaths, "spec-path", "s", []string{}, "Spec path")
_ = cmd.MarkFlagFilename("spec-path", ".spec")

cmd.Flags().BoolVar(&filter.SkipLockValidation, "skip-lock-validation",
os.Getenv("AZLDEV_ENABLE_LOCK_VALIDATION") != "1",
"skip lock file consistency checks")
}

// Function suitable for use as a [cobra.ValidArgsFunction] in a [cobra.Command]. Intended for use
Expand Down
63 changes: 61 additions & 2 deletions internal/app/azldev/core/components/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"path"
"path/filepath"
"strings"
Expand Down Expand Up @@ -34,6 +35,15 @@ func NewResolver(env *azldev.Env) *Resolver {

// Given a component filter, finds all components defined in the environment that match the filter.
func (r *Resolver) FindComponents(filter *ComponentFilter) (components *ComponentSet, err error) {
// The filter's SkipLockValidation field is the primary control. When
// created via AddComponentFilterOptionsToCommand, its default comes from
// the AZLDEV_ENABLE_LOCK_VALIDATION env var. For programmatic callers
// (including tests), also check the env var here so the rollout gate
// applies uniformly.
//nolint:godox // tracked by TODO(lockfiles) tag.
// TODO(lockfiles): remove env var gate; filter.SkipLockValidation becomes sole control.
skipValidation := filter.SkipLockValidation || os.Getenv("AZLDEV_ENABLE_LOCK_VALIDATION") != "1"

// For usability's sake, detect if the caller/user forgot to specify *any* criteria.
if filter.HasNoCriteria() {
slog.Warn("No component selection options were given, no components will be selected.")
Expand All @@ -43,7 +53,12 @@ func (r *Resolver) FindComponents(filter *ComponentFilter) (components *Componen

// If we were asked to include all components, then it's not even worth looking at anything else.
if filter.IncludeAllComponents {
return r.FindAllComponents()
allComps, findErr := r.FindAllComponents()
if findErr != nil {
return allComps, findErr
}

return allComps, r.validateLockFiles(allComps, true, skipValidation)
}

components = NewComponentSet()
Expand Down Expand Up @@ -72,7 +87,7 @@ func (r *Resolver) FindComponents(filter *ComponentFilter) (components *Componen
}
}

return components, err
return components, r.validateLockFiles(components, false, skipValidation)
}

// Finds *all* components defined in the environment.
Expand Down Expand Up @@ -493,3 +508,47 @@ func applyInheritedDefaultsToComponent(

return &resolved, nil
}

// validateLockFiles checks lock file consistency against the resolved component
// set. Skipped when skipValidation is true (set per-filter or via the global
// '--skip-lock-validation' flag).
//
// When checkOrphans is true (i.e., all components are being validated), orphan
// lock files are also detected. On filtered commands, only missing/stale checks
// run — orphan detection is a project-wide invariant that would misfire against
// a subset.
func (r *Resolver) validateLockFiles(resolved *ComponentSet, checkOrphans bool, skipValidation bool) error {
if skipValidation {
return nil
}

reader := r.env.LockReader()
if reader == nil {
return nil
}

// Build resolved config map from the component set.
resolvedConfigs := make(map[string]projectconfig.ComponentConfig, resolved.Len())
for _, comp := range resolved.Components() {
resolvedConfigs[comp.GetName()] = *comp.GetConfig()
}
Comment thread
dmcilvaney marked this conversation as resolved.

stale, orphans, err := reader.ValidateConsistency(resolvedConfigs, checkOrphans)
if err == nil {
return nil
}

// Format fix suggestions at the call site (not in the lockfile package)
// so CLI-specific strings don't leak into the data layer.
const maxIssuesForDetailedSuggestion = 10

if len(orphans) > 0 || len(stale) > maxIssuesForDetailedSuggestion {
r.env.AddFixSuggestion("run 'azldev component update -a' to fix all lock file issues")
} else if len(stale) > 0 {
r.env.AddFixSuggestion(fmt.Sprintf(
"run 'azldev component update %s'",
strings.Join(stale, " ")))
}

return fmt.Errorf("lock file validation failed:\n%w", err)
}
Loading
Loading