From 713980be0bc2ed3a40fc3efb0e85401607b2528e Mon Sep 17 00:00:00 2001 From: Natalia Venditto Date: Fri, 18 Jul 2025 16:00:06 +0200 Subject: [PATCH 1/2] add middleware --- cli/azd/cmd/root.go | 2 + cli/azd/cmd/template_version_registration.go | 12 + .../middleware/template_version_middleware.go | 165 +++++++++++ ...emplate_version_middleware_registration.go | 17 ++ .../template_version_middleware_test.go | 58 ++++ cli/azd/pkg/project/project_config.go | 3 + cli/azd/pkg/templateversion/registration.go | 17 ++ .../pkg/templateversion/template_version.go | 265 ++++++++++++++++++ .../templateversion/template_version_test.go | 243 ++++++++++++++++ schemas/alpha/azure.yaml.json | 5 + schemas/v1.0/azure.yaml.json | 5 + 11 files changed, 792 insertions(+) create mode 100644 cli/azd/cmd/template_version_registration.go create mode 100644 cli/azd/internal/middleware/template_version_middleware.go create mode 100644 cli/azd/internal/middleware/template_version_middleware_registration.go create mode 100644 cli/azd/internal/middleware/template_version_middleware_test.go create mode 100644 cli/azd/pkg/templateversion/registration.go create mode 100644 cli/azd/pkg/templateversion/template_version.go create mode 100644 cli/azd/pkg/templateversion/template_version_test.go diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 0572a8984b8..77d39dbf165 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -25,6 +25,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal/cmd" "github.com/azure/azure-dev/cli/azd/internal/cmd/add" "github.com/azure/azure-dev/cli/azd/internal/cmd/show" + internalMiddleware "github.com/azure/azure-dev/cli/azd/internal/middleware" "github.com/azure/azure-dev/cli/azd/internal/telemetry" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/spf13/cobra" @@ -361,6 +362,7 @@ func NewRootCmd( root. UseMiddleware("debug", middleware.NewDebugMiddleware). UseMiddleware("ux", middleware.NewUxMiddleware). + UseMiddleware("template-version", internalMiddleware.NewTemplateVersionMiddleware). UseMiddlewareWhen("telemetry", middleware.NewTelemetryMiddleware, func(descriptor *actions.ActionDescriptor) bool { return !descriptor.Options.DisableTelemetry }). diff --git a/cli/azd/cmd/template_version_registration.go b/cli/azd/cmd/template_version_registration.go new file mode 100644 index 00000000000..17fd5e33c28 --- /dev/null +++ b/cli/azd/cmd/template_version_registration.go @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +// Intentionally empty as we're just adding comments + +// Registers the template version manager with the IOC container +func init() { + // The template version services are registered automatically by the + // RegisterServices function in the templateversion package +} diff --git a/cli/azd/internal/middleware/template_version_middleware.go b/cli/azd/internal/middleware/template_version_middleware.go new file mode 100644 index 00000000000..bab0ceb6a84 --- /dev/null +++ b/cli/azd/internal/middleware/template_version_middleware.go @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package middleware + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/cmd/middleware" + "github.com/azure/azure-dev/cli/azd/internal/tracing" + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/templateversion" +) + +// NewTemplateVersionMiddleware creates middleware that ensures the template version file exists +// and updates azure.yaml with the version information +func NewTemplateVersionMiddleware(container *ioc.NestedContainer) middleware.Middleware { + return &templateVersionMiddleware{ + container: container, + } +} + +type templateVersionMiddleware struct { + container *ioc.NestedContainer +} + +// Run implements middleware.Middleware +func (m *templateVersionMiddleware) Run(ctx context.Context, next middleware.NextFn) (*actions.ActionResult, error) { + ctx, span := tracing.Start(ctx, "template-version-middleware") + defer span.End() + + // Get a console for debug messages + var console input.Console + err := m.container.Resolve(&console) + if err != nil { + // No console available, just continue with the command + span.SetStatus(1, "Failed to get console for debug messages") + } + + // For now, we'll enable template versioning for all commands + // since we don't have a way to determine the specific command being run + if console != nil { + console.Message(ctx, "DEBUG: Checking template version") + } + + // Get the project path from the context + projectPath := "" + + // Try to create a new AzdContext to get the project path + azdContext, err := azdcontext.NewAzdContext() + if err != nil { + // No AZD context available, continue with the command + if console != nil { + console.Message(ctx, fmt.Sprintf("DEBUG: Failed to create AZD context: %v", err)) + } + span.SetStatus(1, "Failed to create AZD context") + return next(ctx) + } + + projectPath = azdContext.ProjectDirectory() + if console != nil { + console.Message(ctx, fmt.Sprintf("DEBUG: Got project directory: %s", projectPath)) + } + + if projectPath == "" { + // No project path available, continue with the command + if console != nil { + console.Message(ctx, "DEBUG: Project path is empty, skipping template version check") + } + span.SetStatus(1, "No project path found") + return next(ctx) + } + + // Get template version manager + var versionManager *templateversion.Manager + if err = m.container.Resolve(&versionManager); err != nil { + // Version manager not available, continue with the command + span.SetStatus(1, "Template version manager not available") + return next(ctx) + } + + // Ensure template version file exists - errors are handled within the EnsureTemplateVersion method + version, err := versionManager.EnsureTemplateVersion(ctx, projectPath) + if err != nil { + // Log error but continue with the command + span.SetStatus(1, "Failed to ensure template version") + if console != nil { + console.Message(ctx, fmt.Sprintf("DEBUG: Failed to ensure template version: %v", err)) + } + } else if version != "" { + // Only update azure.yaml if we successfully got a version + err = updateAzureYamlVersion(ctx, projectPath, version) + if err != nil { + // Log error but continue with the command + span.SetStatus(1, "Failed to update azure.yaml with template version") + if console != nil { + console.Message(ctx, fmt.Sprintf("DEBUG: Failed to update azure.yaml with template version: %v", err)) + } + } + } + + // Always continue with the next middleware/command + return next(ctx) +} + +// isTemplateCommand returns true if the command requires a template +func isTemplateCommand(commandName string) bool { + templateCommands := []string{ + "init", + "up", + "deploy", + "provision", + "env", // Some env commands may need the template + "pipeline", + "monitor", + } + + for _, cmd := range templateCommands { + if cmd == commandName { + return true + } + } + + return false +} + +// updateAzureYamlVersion updates the azure.yaml file with the template version +func updateAzureYamlVersion(ctx context.Context, projectPath string, version string) error { + ctx, span := tracing.Start(ctx, "update-azure-yaml-version") + defer span.End() + + if projectPath == "" { + return nil + } + + // Get the azure.yaml path + azureYamlPath := filepath.Join(projectPath, "azure.yaml") + + // Load the project config + projectConfig, err := project.Load(ctx, azureYamlPath) + if err != nil { + return err + } + + if projectConfig == nil { + return nil + } + + // Check if version already matches + if projectConfig.TrackingId == version { + return nil + } + + // Update the tracking ID + projectConfig.TrackingId = version + + // Save the updated config + return project.Save(ctx, projectConfig, azureYamlPath) +} diff --git a/cli/azd/internal/middleware/template_version_middleware_registration.go b/cli/azd/internal/middleware/template_version_middleware_registration.go new file mode 100644 index 00000000000..1977df8c608 --- /dev/null +++ b/cli/azd/internal/middleware/template_version_middleware_registration.go @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package middleware + +import ( + "github.com/azure/azure-dev/cli/azd/cmd/middleware" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" +) + +// RegisterTemplateVersionMiddleware registers the template version middleware with the CLI +func RegisterTemplateVersionMiddleware(runner *middleware.MiddlewareRunner) error { + // Register the middleware with a factory function that uses dependency injection + return runner.Use("template-version", func(container *ioc.NestedContainer) middleware.Middleware { + return NewTemplateVersionMiddleware(container) + }) +} diff --git a/cli/azd/internal/middleware/template_version_middleware_test.go b/cli/azd/internal/middleware/template_version_middleware_test.go new file mode 100644 index 00000000000..38ed3cdbb06 --- /dev/null +++ b/cli/azd/internal/middleware/template_version_middleware_test.go @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package middleware + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Copy of the isTemplateCommand function from template_version_middleware.go for testing +func isTemplateCommandTest(commandName string) bool { + templateCommands := []string{ + "init", + "up", + "deploy", + "provision", + "env", // Some env commands may need the template + "pipeline", + "monitor", + } + + for _, cmd := range templateCommands { + if cmd == commandName { + return true + } + } + + return false +} + +// Test the isTemplateCommand function that's used by the middleware +func TestIsTemplateCommand(t *testing.T) { + testCases := []struct { + commandName string + expected bool + }{ + {"init", true}, + {"up", true}, + {"deploy", true}, + {"provision", true}, + {"env", true}, + {"pipeline", true}, + {"monitor", true}, + {"version", false}, + {"login", false}, + {"logout", false}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("Command: %s", tc.commandName), func(t *testing.T) { + result := isTemplateCommandTest(tc.commandName) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/cli/azd/pkg/project/project_config.go b/cli/azd/pkg/project/project_config.go index ccb50cd74cf..ffc7b39f829 100644 --- a/cli/azd/pkg/project/project_config.go +++ b/cli/azd/pkg/project/project_config.go @@ -40,6 +40,9 @@ type ProjectConfig struct { Workflows workflow.WorkflowMap `yaml:"workflows,omitempty"` Cloud *cloud.Config `yaml:"cloud,omitempty"` Resources map[string]*ResourceConfig `yaml:"resources,omitempty"` + // TrackingId is the template version in format YYYY-MM-DD- + // This is used to track the version of the template being used + TrackingId string `yaml:"tracking_id,omitempty"` *ext.EventDispatcher[ProjectLifecycleEventArgs] `yaml:"-"` } diff --git a/cli/azd/pkg/templateversion/registration.go b/cli/azd/pkg/templateversion/registration.go new file mode 100644 index 00000000000..f370ed88c9c --- /dev/null +++ b/cli/azd/pkg/templateversion/registration.go @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package templateversion + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" +) + +// RegisterServices registers the template version services with the IoC container +func RegisterServices(container *ioc.NestedContainer) { + container.MustRegisterSingleton(func(console input.Console, runner exec.CommandRunner) *Manager { + return NewManager(console, runner) + }) +} diff --git a/cli/azd/pkg/templateversion/template_version.go b/cli/azd/pkg/templateversion/template_version.go new file mode 100644 index 00000000000..a8970dbacee --- /dev/null +++ b/cli/azd/pkg/templateversion/template_version.go @@ -0,0 +1,265 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package templateversion + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/input" +) + +const ( + // VersionFileName is the name of the file that contains the template version + VersionFileName = "AZD_TEMPLATE_VERSION" + + // ReadOnlyFilePerms sets the file as read-only + ReadOnlyFilePerms = 0444 +) + +// VersionInfo represents the parsed template version information +type VersionInfo struct { + // Date in YYYY-MM-DD format + Date string `json:"date"` + + // CommitHash is the short git commit hash + CommitHash string `json:"commit_hash"` + + // FullVersion is the complete version string (YYYY-MM-DD-) + FullVersion string `json:"full_version"` +} + +// Manager provides operations for template versioning +type Manager struct { + console input.Console + runner exec.CommandRunner +} + +// NewManager creates a new template version manager +func NewManager(console input.Console, runner exec.CommandRunner) *Manager { + return &Manager{ + console: console, + runner: runner, + } +} + +// GetShortCommitHash returns the short commit hash for the current repository +func (m *Manager) GetShortCommitHash(ctx context.Context, projectPath string) (string, error) { + // First check if git is initialized in the directory + checkArgs := exec.RunArgs{ + Cmd: "git", + Args: []string{"rev-parse", "--is-inside-work-tree"}, + } + + // Set working directory + checkArgs = checkArgs.WithCwd(projectPath) + + // Check if we're in a git repository + _, checkErr := m.runner.Run(ctx, checkArgs) + if checkErr != nil { + // Not in a git repository or git not installed, return a fallback hash + return "dev", nil + } + + // Get the short commit hash + args := exec.RunArgs{ + Cmd: "git", + Args: []string{"rev-parse", "--short", "HEAD"}, + } + + // Set working directory + args = args.WithCwd(projectPath) + + result, err := m.runner.Run(ctx, args) + if err != nil { + // If we can't get the hash, use a fallback + return "unknown", nil + } + + hash := strings.TrimSpace(result.Stdout) + if hash == "" { + return "unknown", nil + } + + return hash, nil +} + +// CreateVersionFile creates the AZD_TEMPLATE_VERSION file with the current date and git commit hash +func (m *Manager) CreateVersionFile(ctx context.Context, projectPath string) (string, error) { + // Get current date in YYYY-MM-DD format + currentDate := time.Now().Format("2006-01-02") + + // Get the git short commit hash + commitHash, err := m.GetShortCommitHash(ctx, projectPath) + if err != nil { + m.console.Message(ctx, fmt.Sprintf("WARNING: Error getting git hash: %v, using fallback", err)) + commitHash = "dev" + } + + // Create the version string + versionString := fmt.Sprintf("%s-%s", currentDate, commitHash) + + // Create the file path + filePath := filepath.Join(projectPath, VersionFileName) + + // Check if the file already exists and remove it if needed + if _, err := os.Stat(filePath); err == nil { + // File exists, try to make it writable before removing + err = os.Chmod(filePath, 0666) + if err != nil { + m.console.Message(ctx, fmt.Sprintf("WARNING: Failed to change file permissions: %v", err)) + // Continue anyway, as removal might still work + } + + err = os.Remove(filePath) + if err != nil { + return "", fmt.Errorf("failed to remove existing version file %s: %w", filePath, err) + } + } + + // Write the file with read-only permissions + err = os.WriteFile(filePath, []byte(versionString), ReadOnlyFilePerms) + if err != nil { + // Check if the error is due to permissions + if os.IsPermission(err) { + m.console.Message(ctx, fmt.Sprintf("ERROR: Permission denied creating file %s", filePath)) + return "", fmt.Errorf("permission denied creating version file %s: %w", filePath, err) + } + + return "", fmt.Errorf("failed to create version file %s: %w", filePath, err) + } + + m.console.Message(ctx, fmt.Sprintf("Created template version file at %s: %s", filePath, versionString)) + m.console.Message(ctx, "Please commit this file to your repository.") + + return versionString, nil +} + +// ReadVersionFile reads the AZD_TEMPLATE_VERSION file and returns the version string +func (m *Manager) ReadVersionFile(projectPath string) (string, error) { + filePath := filepath.Join(projectPath, VersionFileName) + + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return "", nil // File doesn't exist, not an error + } + + // Check if the error is due to permissions + if os.IsPermission(err) { + return "", fmt.Errorf("permission denied reading version file %s: %w", filePath, err) + } + + return "", fmt.Errorf("failed to read version file %s: %w", filePath, err) + } + + version := strings.TrimSpace(string(data)) + if version == "" { + return "", nil // Empty file, treat as non-existent + } + + return version, nil +} + +// ParseVersionString parses a version string into a VersionInfo +func ParseVersionString(version string) (*VersionInfo, error) { + if version == "" { + return nil, fmt.Errorf("empty version string") + } + + parts := strings.Split(version, "-") + + // Version should be in format YYYY-MM-DD-hash, so we need at least 4 parts + if len(parts) < 4 { + return nil, fmt.Errorf("invalid version string format: %s, expected YYYY-MM-DD-hash", version) + } + + // Validate date format (YYYY-MM-DD) + dateStr := strings.Join(parts[:3], "-") + _, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return nil, fmt.Errorf("invalid date format in version string: %s, expected YYYY-MM-DD", dateStr) + } + + // Commit hash is the last part (or all remaining parts joined if there are more than 4) + commitHash := strings.Join(parts[3:], "-") + if commitHash == "" { + return nil, fmt.Errorf("missing commit hash in version string: %s", version) + } + + return &VersionInfo{ + Date: dateStr, + CommitHash: commitHash, + FullVersion: version, + }, nil +} + +// EnsureTemplateVersion ensures that the AZD_TEMPLATE_VERSION file exists +// If it doesn't exist, it creates it and returns the version string +// If it does exist, it reads the version string and returns it +func (m *Manager) EnsureTemplateVersion(ctx context.Context, projectPath string) (string, error) { + // Print a debug message about the project path + m.console.Message(ctx, fmt.Sprintf("Ensuring template version for project path: %s", projectPath)) + + if projectPath == "" { + m.console.Message(ctx, "ERROR: Project path is empty") + return "", fmt.Errorf("project path cannot be empty") + } + + // Check if project path exists + _, err := os.Stat(projectPath) + if err != nil { + if os.IsNotExist(err) { + m.console.Message(ctx, fmt.Sprintf("ERROR: Project path does not exist: %s", projectPath)) + return "", fmt.Errorf("project path does not exist: %s", projectPath) + } + m.console.Message(ctx, fmt.Sprintf("ERROR: Failed to access project path: %v", err)) + return "", fmt.Errorf("failed to access project path: %w", err) + } + + // Try to read the version file + version, err := m.ReadVersionFile(projectPath) + if err != nil { + // Log the error but continue with creating a new file + m.console.Message(ctx, fmt.Sprintf("Warning: Failed to read version file: %v", err)) + version = "" + } + + // If the file doesn't exist or is empty, create it + if version == "" { + createdVersion, err := m.CreateVersionFile(ctx, projectPath) + if err != nil { + return "", fmt.Errorf("failed to create template version file: %w", err) + } + return createdVersion, nil + } + + // Validate the existing version format + _, err = ParseVersionString(version) + if err != nil { + m.console.Message(ctx, fmt.Sprintf("Warning: Invalid version format in %s: %v", VersionFileName, err)) + m.console.Message(ctx, "Creating a new version file with the correct format...") + + // Rename the old file to preserve it + oldPath := filepath.Join(projectPath, VersionFileName) + backupPath := filepath.Join(projectPath, VersionFileName+".bak") + + // Try to rename, but continue even if it fails + _ = os.Rename(oldPath, backupPath) + + // Create a new file with the correct format + createdVersion, err := m.CreateVersionFile(ctx, projectPath) + if err != nil { + return "", fmt.Errorf("failed to create template version file: %w", err) + } + return createdVersion, nil + } + + return version, nil +} diff --git a/cli/azd/pkg/templateversion/template_version_test.go b/cli/azd/pkg/templateversion/template_version_test.go new file mode 100644 index 00000000000..0ef24777d01 --- /dev/null +++ b/cli/azd/pkg/templateversion/template_version_test.go @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package templateversion + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestGetShortCommitHash(t *testing.T) { + console := mocks.NewMockConsole(t) + runner := mocks.NewMockCommandRunner(t) + manager := NewManager(console, runner) + + // Mock the git command + runner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(args exec.RunArgs) bool { + return args.Cmd == "git" && len(args.Args) == 3 && args.Args[0] == "rev-parse" + })).Return(&exec.RunResult{ + ExitCode: 0, + Stdout: "abc1234\n", + }, nil) + + // Call the function + hash, err := manager.GetShortCommitHash(context.Background(), "/test/path") + + // Assert + assert.NoError(t, err) + assert.Equal(t, "abc1234", hash) +} + +func TestCreateVersionFile(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "templateversion_test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + console := mocks.NewMockConsole(t) + runner := mocks.NewMockCommandRunner(t) + manager := NewManager(console, runner) + + // Mock the console + console.EXPECT().Message(mock.Anything, mock.Anything).Return() + + // Mock the git command + runner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(args exec.RunArgs) bool { + return args.Cmd == "git" && len(args.Args) == 3 && args.Args[0] == "rev-parse" + })).Return(&exec.RunResult{ + ExitCode: 0, + Stdout: "abc1234\n", + }, nil) + + // Call the function + version, err := manager.CreateVersionFile(context.Background(), tempDir) + require.NoError(t, err) + + // Assert that the file exists + filePath := filepath.Join(tempDir, VersionFileName) + assert.FileExists(t, filePath) + + // Read the file content + content, err := os.ReadFile(filePath) + require.NoError(t, err) + + // Assert the content + assert.Equal(t, version, string(content)) + + // Check file permissions + info, err := os.Stat(filePath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(ReadOnlyFilePerms), info.Mode().Perm()) +} + +func TestReadVersionFile(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "templateversion_test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a version file + filePath := filepath.Join(tempDir, VersionFileName) + versionContent := "2025-07-18-abc1234" + err = os.WriteFile(filePath, []byte(versionContent), 0644) + require.NoError(t, err) + + console := mocks.NewMockConsole(t) + runner := mocks.NewMockCommandRunner(t) + manager := NewManager(console, runner) + + // Call the function + version, err := manager.ReadVersionFile(tempDir) + require.NoError(t, err) + + // Assert + assert.Equal(t, versionContent, version) +} + +func TestReadVersionFileNotExists(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "templateversion_test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + console := mocks.NewMockConsole(t) + runner := mocks.NewMockCommandRunner(t) + manager := NewManager(console, runner) + + // Call the function + version, err := manager.ReadVersionFile(tempDir) + require.NoError(t, err) + + // Assert + assert.Equal(t, "", version) +} + +func TestParseVersionString(t *testing.T) { + tests := []struct { + name string + version string + expectError bool + expectedErr string + expectedDate string + expectedHash string + }{ + { + name: "Valid version", + version: "2025-07-18-abc1234", + expectError: false, + expectedDate: "2025-07-18", + expectedHash: "abc1234", + }, + { + name: "Empty version", + version: "", + expectError: true, + expectedErr: "empty version string", + }, + { + name: "Too few parts", + version: "2023-04-05", + expectError: true, + expectedErr: "invalid version string format", + }, + { + name: "Invalid date format", + version: "20230-04-05-abcdef1", + expectError: true, + expectedErr: "invalid date format", + }, + { + name: "Invalid format", + version: "invalid-version", + expectError: true, + expectedErr: "invalid version string format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info, err := ParseVersionString(tt.version) + if tt.expectError { + assert.Error(t, err) + if tt.expectedErr != "" { + assert.Contains(t, err.Error(), tt.expectedErr) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedDate, info.Date) + assert.Equal(t, tt.expectedHash, info.CommitHash) + assert.Equal(t, tt.version, info.FullVersion) + } + }) + } +} + +func TestEnsureTemplateVersion_Exists(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "templateversion_test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a version file + filePath := filepath.Join(tempDir, VersionFileName) + versionContent := "2025-07-18-abc1234" + err = os.WriteFile(filePath, []byte(versionContent), 0644) + require.NoError(t, err) + + console := mocks.NewMockConsole(t) + runner := mocks.NewMockCommandRunner(t) + manager := NewManager(console, runner) + + // Call the function + version, err := manager.EnsureTemplateVersion(context.Background(), tempDir) + require.NoError(t, err) + + // Assert + assert.Equal(t, versionContent, version) +} + +func TestEnsureTemplateVersion_NotExists(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "templateversion_test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + console := mocks.NewMockConsole(t) + runner := mocks.NewMockCommandRunner(t) + manager := NewManager(console, runner) + + // Mock the console + console.EXPECT().Message(mock.Anything, mock.Anything).Return() + + // Mock the git command + runner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(args exec.RunArgs) bool { + return args.Cmd == "git" && len(args.Args) == 3 && args.Args[0] == "rev-parse" + })).Return(&exec.RunResult{ + ExitCode: 0, + Stdout: "abc1234\n", + }, nil) + + // Call the function + version, err := manager.EnsureTemplateVersion(context.Background(), tempDir) + require.NoError(t, err) + + // Assert that the file exists + filePath := filepath.Join(tempDir, VersionFileName) + assert.FileExists(t, filePath) + + // Read the file content + content, err := os.ReadFile(filePath) + require.NoError(t, err) + + // Assert the content + assert.Equal(t, version, string(content)) +} diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 089115279a9..303c85ed9c3 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -14,6 +14,11 @@ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", "description": "The application name. Only lowercase letters, numbers, and hyphens (-) are allowed. The name must start and end with a letter or number." }, + "tracking_id": { + "type": "string", + "title": "Template version tracking ID", + "description": "The template version in format YYYY-MM-DD-. This is used to track the version of the template being used." + }, "resourceGroup": { "type": "string", "minLength": 3, diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 8c6e0e71d1f..2509c6f0634 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -14,6 +14,11 @@ "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", "description": "The application name. Only lowercase letters, numbers, and hyphens (-) are allowed. The name must start and end with a letter or number." }, + "tracking_id": { + "type": "string", + "title": "Template version tracking ID", + "description": "The template version in format YYYY-MM-DD-. This is used to track the version of the template being used." + }, "resourceGroup": { "type": "string", "minLength": 3, From 622b9327c3e49d6b6f32a7b6acb646c4d4c8015f Mon Sep 17 00:00:00 2001 From: Natalia Venditto Date: Mon, 21 Jul 2025 09:41:19 +0200 Subject: [PATCH 2/2] dev: template version and docs --- .gitignore | 4 +- .../middleware/template_version_middleware.go | 34 ++++- cli/azd/pkg/templateversion/README.md | 141 ++++++++++++++++++ cli/azd/pkg/templateversion/examples.md | 43 ++++++ .../templateversion/template-version-flow.md | 35 +++++ .../pkg/templateversion/template_version.go | 38 ++++- 6 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 cli/azd/pkg/templateversion/README.md create mode 100644 cli/azd/pkg/templateversion/examples.md create mode 100644 cli/azd/pkg/templateversion/template-version-flow.md diff --git a/.gitignore b/.gitignore index 8783695325e..a9acb861127 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,6 @@ test-results/ *.terraform* # Finder state files -.DS_Store \ No newline at end of file +.DS_Store + +/temp/* \ No newline at end of file diff --git a/cli/azd/internal/middleware/template_version_middleware.go b/cli/azd/internal/middleware/template_version_middleware.go index bab0ceb6a84..a959770922d 100644 --- a/cli/azd/internal/middleware/template_version_middleware.go +++ b/cli/azd/internal/middleware/template_version_middleware.go @@ -12,6 +12,7 @@ import ( "github.com/azure/azure-dev/cli/azd/cmd/middleware" "github.com/azure/azure-dev/cli/azd/internal/tracing" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" + "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/pkg/project" @@ -78,15 +79,34 @@ func (m *templateVersionMiddleware) Run(ctx context.Context, next middleware.Nex } // Get template version manager - var versionManager *templateversion.Manager - if err = m.container.Resolve(&versionManager); err != nil { - // Version manager not available, continue with the command - span.SetStatus(1, "Template version manager not available") + var runner exec.CommandRunner + if err = m.container.Resolve(&runner); err != nil { + // Runner not available, continue with the command + if console != nil { + console.Message(ctx, fmt.Sprintf("DEBUG: Failed to resolve command runner: %v", err)) + } + span.SetStatus(1, "Command runner not available") return next(ctx) } + + // Create version manager directly instead of using IoC container + versionManager := templateversion.NewManager(console, runner) + + if console != nil { + console.Message(ctx, "DEBUG: Successfully created version manager directly") + } // Ensure template version file exists - errors are handled within the EnsureTemplateVersion method + if console != nil { + console.Message(ctx, fmt.Sprintf("DEBUG: Calling EnsureTemplateVersion with projectPath: %s", projectPath)) + } + version, err := versionManager.EnsureTemplateVersion(ctx, projectPath) + + if console != nil { + console.Message(ctx, fmt.Sprintf("DEBUG: EnsureTemplateVersion returned version: %s, err: %v", version, err)) + } + if err != nil { // Log error but continue with the command span.SetStatus(1, "Failed to ensure template version") @@ -95,6 +115,10 @@ func (m *templateVersionMiddleware) Run(ctx context.Context, next middleware.Nex } } else if version != "" { // Only update azure.yaml if we successfully got a version + if console != nil { + console.Message(ctx, fmt.Sprintf("DEBUG: Updating azure.yaml with version: %s", version)) + } + err = updateAzureYamlVersion(ctx, projectPath, version) if err != nil { // Log error but continue with the command @@ -102,6 +126,8 @@ func (m *templateVersionMiddleware) Run(ctx context.Context, next middleware.Nex if console != nil { console.Message(ctx, fmt.Sprintf("DEBUG: Failed to update azure.yaml with template version: %v", err)) } + } else if console != nil { + console.Message(ctx, "DEBUG: Successfully updated azure.yaml with version") } } diff --git a/cli/azd/pkg/templateversion/README.md b/cli/azd/pkg/templateversion/README.md new file mode 100644 index 00000000000..fd56574a40a --- /dev/null +++ b/cli/azd/pkg/templateversion/README.md @@ -0,0 +1,141 @@ +# Template Version Management for Azure Developer CLI + +## Overview +This package implements the CalVer (Calendar Versioning) functionality for Azure Developer CLI templates. It automatically creates and manages version files within templated projects, enabling better tracking, debugging, and reproducibility of templates. + +## Why Template Versioning? + +### Problem Statement +When users initialize projects from templates, there's no built-in mechanism to track which version of the template was used. This makes it challenging to: +- Debug issues that might be template-version specific +- Ensure consistent environments across team members +- Understand when a project's template source was last updated +- Reference the exact template state for reproducibility + +### Solution +The Template Version Manager introduces a standardized approach to version tracking by: +1. Creating an `AZD_TEMPLATE_VERSION` file in the project directory +2. Using a CalVer format: `YYYY-MM-DD-` +3. Setting the file as read-only to prevent accidental modification +4. Adding the version information to `azure.yaml` for easier programmatic access + +## How It Works + +### Version Format +- **Date Component**: `YYYY-MM-DD` - The date when the template was initialized +- **Git Hash Component**: Short git commit hash from the template source +- **Example**: `2025-07-21-713980be` + +### Implementation Details +The version management is implemented as middleware in the CLI command pipeline. When any template-dependent command runs (like `azd init`, `azd up`, `azd deploy`, etc.), the middleware: + +1. Checks if the `AZD_TEMPLATE_VERSION` file exists +2. If not found, creates it with the current date and git hash +3. Makes the file read-only (permissions: 0444) +4. Updates `azure.yaml` with a `tracking_id` field containing the version +5. Prompts the user to commit the file to their repository + +The middleware is designed to be non-invasive and only creates the version file once. Subsequent commands will use the existing version file if present. + +## Package Components + +### Key Files +- `template_version.go` - Core implementation of version manager functionality +- `template_version_test.go` - Unit tests for version management +- `registration.go` - IoC container registration for version manager + +### Key Types +- `Manager` - Main service that handles version file operations +- `VersionInfo` - Struct representing parsed version information + +### Key Functions +- `EnsureTemplateVersion` - Main function that checks/creates version files +- `CreateVersionFile` - Creates the version file with proper format and permissions +- `ReadVersionFile` - Reads and validates existing version files +- `GetShortCommitHash` - Retrieves the git hash for version creation +- `ParseVersionString` - Parses a version string into structured data + +## Testing It Yourself + +### Prerequisites +- Go development environment +- Azure Developer CLI source code +- Git installed + +### How to Test + +1. **Build the CLI with template versioning**: + ```bash + cd /path/to/azure-dev + go build -o ./cli/azd/azd ./cli/azd + ``` + +2. **Initialize a new project from a template**: + ```bash + mkdir -p test-app + cd test-app + /path/to/azure-dev/cli/azd/azd init --template azure-samples/todo-nodejs-mongo-aca + ``` + +3. **Run a template-related command to trigger the middleware**: + ```bash + /path/to/azure-dev/cli/azd/azd env list --debug + ``` + +4. **Verify the version file was created**: + ```bash + cat AZD_TEMPLATE_VERSION + ``` + You should see output like: `2025-07-21-713980be` + +5. **Check azure.yaml for tracking_id**: + ```bash + grep tracking_id azure.yaml + ``` + It should show: `tracking_id: 2025-07-21-713980be` + +### Debugging +For verbose output, run commands with debug logging: +```bash +AZURE_DEV_TRACE_LEVEL=DEBUG /path/to/azure-dev/cli/azd/azd env list --debug +``` + +## Code Example: Using the Template Version Manager + +```go +// Create a new manager +manager := templateversion.NewManager(console, runner) + +// Ensure a template version file exists (creates if missing) +version, err := manager.EnsureTemplateVersion(ctx, projectPath) +if err != nil { + // Handle error +} + +// Parse version information +versionInfo, err := templateversion.ParseVersionString(version) +if err != nil { + // Handle error +} + +// Access version components +date := versionInfo.Date // "2025-07-21" +hash := versionInfo.CommitHash // "713980be" +fullVersion := versionInfo.FullVersion // "2025-07-21-713980be" +``` + +## Benefits for the Team + +1. **Enhanced Debugging**: When issues arise, the exact template version provides crucial context +2. **Template Evolution**: Track how templates evolve over time and when projects were last updated +3. **Reproducibility**: New team members can easily understand which template version a project is using +4. **Auditing**: Simplifies compliance by tracking template sources +5. **Compatibility**: Helps identify potential issues when updating templates or the CLI itself + +## Future Enhancements +- Add template update detection to notify users when newer template versions are available +- Provide commands to explicitly update templates to newer versions +- Enhance version parsing to handle more complex template hierarchies + +## Feedback and Contributions +Please provide feedback and suggestions for improving the template version management system. The current implementation is designed to be lightweight and unobtrusive while providing valuable metadata for project maintenance. diff --git a/cli/azd/pkg/templateversion/examples.md b/cli/azd/pkg/templateversion/examples.md new file mode 100644 index 00000000000..8d1e0cad3f4 --- /dev/null +++ b/cli/azd/pkg/templateversion/examples.md @@ -0,0 +1,43 @@ +# Template Version Examples + +## Example 1: Examining Version Contents + +```bash +# View the template version +cat AZD_TEMPLATE_VERSION +# Output: 2025-07-21-713980be + +# Check the tracking ID in azure.yaml +grep tracking_id azure.yaml +# Output: tracking_id: 2025-07-21-713980be +``` + +## Example 2: Command Sequence with Template Versioning + +```bash +# Initialize a project from template +azd init --template azure-samples/todo-nodejs-mongo-aca + +# Run a command that triggers template version check +azd env list + +# Verify version file creation +ls -la AZD_TEMPLATE_VERSION +# Should show: -r--r--r-- ... AZD_TEMPLATE_VERSION + +# Check the file contents +cat AZD_TEMPLATE_VERSION +# Output: 2025-07-21-713980be (or similar with current date) +``` + +## Example 3: Using Debug Mode + +```bash +# Run with debug logging for detailed information +AZURE_DEV_TRACE_LEVEL=DEBUG azd env list --debug + +# Look for template version middleware output in logs +# You'll see entries like: +# "DEBUG: Checking template version" +# "Creating template version file at /path/to/project/AZD_TEMPLATE_VERSION: 2025-07-21-713980be" +``` diff --git a/cli/azd/pkg/templateversion/template-version-flow.md b/cli/azd/pkg/templateversion/template-version-flow.md new file mode 100644 index 00000000000..102799effc8 --- /dev/null +++ b/cli/azd/pkg/templateversion/template-version-flow.md @@ -0,0 +1,35 @@ +```mermaid +flowchart TD + title["Template Versioning Flow"] + + A[User initiates template-dependent command] --> B{Template Version Middleware} + + B -->|Step 1: Check| C{AZD_TEMPLATE_VERSION exists?} + + C -->|Yes| D[Read version from file] + C -->|No| E[Create new version file] + + E --> F[Get current date: YYYY-MM-DD] + F --> G[Get short git commit hash] + G --> H[Combine date + hash] + H --> I[Write to AZD_TEMPLATE_VERSION file] + I --> J[Set file permissions to read-only] + + D --> K[Parse version string] + J --> K + + K --> L[Update azure.yaml with tracking_id] + + L --> M{Update complete?} + M -->|Yes| N[Continue with command execution] + M -->|No| O[Log warning but continue] + O --> N + + classDef process fill:#f9f,stroke:#333,stroke-width:2px; + classDef decision fill:#bbf,stroke:#333,stroke-width:2px; + classDef file fill:#bfb,stroke:#333,stroke-width:2px; + + class A,F,G,H,I,J,L process; + class B,C,M decision; + class D,E,K file; +``` diff --git a/cli/azd/pkg/templateversion/template_version.go b/cli/azd/pkg/templateversion/template_version.go index a8970dbacee..dc9b11c8c97 100644 --- a/cli/azd/pkg/templateversion/template_version.go +++ b/cli/azd/pkg/templateversion/template_version.go @@ -107,10 +107,12 @@ func (m *Manager) CreateVersionFile(ctx context.Context, projectPath string) (st // Create the file path filePath := filepath.Join(projectPath, VersionFileName) + m.console.Message(ctx, fmt.Sprintf("DEBUG: Creating version file at: %s with content: %s", filePath, versionString)) // Check if the file already exists and remove it if needed if _, err := os.Stat(filePath); err == nil { // File exists, try to make it writable before removing + m.console.Message(ctx, "DEBUG: File already exists, attempting to make writable before removing") err = os.Chmod(filePath, 0666) if err != nil { m.console.Message(ctx, fmt.Sprintf("WARNING: Failed to change file permissions: %v", err)) @@ -119,11 +121,13 @@ func (m *Manager) CreateVersionFile(ctx context.Context, projectPath string) (st err = os.Remove(filePath) if err != nil { + m.console.Message(ctx, fmt.Sprintf("ERROR: Failed to remove existing version file: %v", err)) return "", fmt.Errorf("failed to remove existing version file %s: %w", filePath, err) } } // Write the file with read-only permissions + m.console.Message(ctx, "DEBUG: Writing version file with read-only permissions") err = os.WriteFile(filePath, []byte(versionString), ReadOnlyFilePerms) if err != nil { // Check if the error is due to permissions @@ -132,9 +136,17 @@ func (m *Manager) CreateVersionFile(ctx context.Context, projectPath string) (st return "", fmt.Errorf("permission denied creating version file %s: %w", filePath, err) } + m.console.Message(ctx, fmt.Sprintf("ERROR: Failed to write version file: %v", err)) return "", fmt.Errorf("failed to create version file %s: %w", filePath, err) } + // Verify the file was created + if _, statErr := os.Stat(filePath); statErr != nil { + m.console.Message(ctx, fmt.Sprintf("ERROR: File creation verification failed: %v", statErr)) + } else { + m.console.Message(ctx, "DEBUG: File creation verification succeeded") + } + m.console.Message(ctx, fmt.Sprintf("Created template version file at %s: %s", filePath, versionString)) m.console.Message(ctx, "Please commit this file to your repository.") @@ -223,7 +235,23 @@ func (m *Manager) EnsureTemplateVersion(ctx context.Context, projectPath string) return "", fmt.Errorf("failed to access project path: %w", err) } + // Check if the project path is a git repository + checkArgs := exec.RunArgs{ + Cmd: "git", + Args: []string{"rev-parse", "--is-inside-work-tree"}, + Cwd: projectPath, + } + _, checkErr := m.runner.Run(ctx, checkArgs) + if checkErr != nil { + m.console.Message(ctx, fmt.Sprintf("DEBUG: Not a git repository or git error: %v", checkErr)) + } else { + m.console.Message(ctx, "DEBUG: Confirmed path is a git repository") + } + // Try to read the version file + versionFilePath := filepath.Join(projectPath, VersionFileName) + m.console.Message(ctx, fmt.Sprintf("DEBUG: Checking for version file at: %s", versionFilePath)) + version, err := m.ReadVersionFile(projectPath) if err != nil { // Log the error but continue with creating a new file @@ -233,11 +261,16 @@ func (m *Manager) EnsureTemplateVersion(ctx context.Context, projectPath string) // If the file doesn't exist or is empty, create it if version == "" { + m.console.Message(ctx, "DEBUG: Version file doesn't exist or is empty, creating it now") createdVersion, err := m.CreateVersionFile(ctx, projectPath) if err != nil { + m.console.Message(ctx, fmt.Sprintf("ERROR: Failed to create version file: %v", err)) return "", fmt.Errorf("failed to create template version file: %w", err) } + m.console.Message(ctx, fmt.Sprintf("DEBUG: Successfully created version file with: %s", createdVersion)) return createdVersion, nil + } else { + m.console.Message(ctx, fmt.Sprintf("DEBUG: Found existing version: %s", version)) } // Validate the existing version format @@ -251,11 +284,14 @@ func (m *Manager) EnsureTemplateVersion(ctx context.Context, projectPath string) backupPath := filepath.Join(projectPath, VersionFileName+".bak") // Try to rename, but continue even if it fails - _ = os.Rename(oldPath, backupPath) + if renameErr := os.Rename(oldPath, backupPath); renameErr != nil { + m.console.Message(ctx, fmt.Sprintf("DEBUG: Failed to rename old version file: %v", renameErr)) + } // Create a new file with the correct format createdVersion, err := m.CreateVersionFile(ctx, projectPath) if err != nil { + m.console.Message(ctx, fmt.Sprintf("ERROR: Failed to create version file with correct format: %v", err)) return "", fmt.Errorf("failed to create template version file: %w", err) } return createdVersion, nil