Saltar al contenido
Microsoft scripts

Scripts de Intune

Renombrar dispositivos desde un CSV

Cambia en bloque el nombre de equipos Windows administrados con la acción remota setDeviceName de Intune, a partir de un CSV con el nombre actual y el nuevo.

Antes de correrloNo pide confirmación por equipo; usa -WhatIf o -DryRun primero. Cambiar el nombre puede afectar reglas, grupos dinámicos o scripts que dependan del nombre. Usa endpoints beta.

Ejemplo en México

Cadena de tiendas de conveniencia en Hermosillo, 220 personas

La gerencia cambió la convención de nombres para identificar sucursal y tipo de equipo y hay 80 computadoras con nombres genéricos.

.\rename-devices-from-csv.ps1 -CsvPath ".\renombrar.csv" -WhatIf

La vista previa confirma que 80 nombres son válidos y los equipos existen. Tras ejecutarlo, las computadoras toman el nombre nuevo al reiniciar.

Cómo implementarlo

  1. Crea un CSV con las columnas DeviceName (nombre actual en Intune) y NewName.
  2. Cuida los nombres nuevos: máximo 15 caracteres, solo letras, números y guiones, y no solo dígitos.
  3. Corre con -CsvPath y -WhatIf (o -DryRun "true") para validar nombres y equipos sin enviar nada.
  4. Ejecuta sin simulación; el cambio se aplica en el siguiente contacto del equipo y suele requerir reinicio.
Ver el código completo de rename-devices-from-csv.ps1
<#
.TITLE
    Rename Devices from CSV

.SYNOPSIS
    Bulk-renames Intune Windows devices from a CSV mapping file using the setDeviceName remote action.

.DESCRIPTION
    This script reads a CSV file with DeviceName and NewName columns and triggers the
    Intune setDeviceName remote action for each matching Windows device. Every rename
    supports -WhatIf preview, name validation catches illegal computer names before
    any action is sent, and results are summarized per device. Devices are renamed on
    their next check-in; Windows devices typically need a restart for the new name to
    fully apply.

.TAGS
    Devices,Operational

.MINROLE
    Intune Administrator

.PERMISSIONS
    DeviceManagementManagedDevices.PrivilegedOperations.All,DeviceManagementManagedDevices.Read.All

.AUTHOR
    Ugur Koc

.VERSION
    1.3

.CHANGELOG
    1.3 - Added a portal-safe DryRun mode for Azure Automation rename previews
    1.2 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors
    1.1 - Azure Automation now records script progress, outcomes, and summaries in job history
    1.0 - Initial release

.LASTUPDATE
    2026-07-30

.EXAMPLE
    .\rename-devices-from-csv.ps1 -CsvPath ".\renames.csv" -WhatIf
    Previews all renames without sending any action

.EXAMPLE
    .\rename-devices-from-csv.ps1 -CsvPath ".\renames.csv"
    Renames all devices listed in the CSV (columns: DeviceName,NewName)

.EXAMPLE
    .\rename-devices-from-csv.ps1 -CsvContent $csvContent
    Uses CSV text supplied at runtime, which is suitable for Azure Automation

.EXAMPLE
    .\rename-devices-from-csv.ps1 -CsvContent $csvContent -DryRun "true"
    Validates devices and proposed names without sending a rename action

.NOTES
    - Requires Microsoft.Graph.Authentication module
    - CSV must contain the columns DeviceName (current Intune device name) and NewName
    - Windows computer names: max 15 characters, letters/digits/hyphens, not all digits
    - The rename applies at next device check-in; a restart completes it on Windows
    - setDeviceName requires the DeviceManagementManagedDevices.PrivilegedOperations.All scope
    - Uses beta Graph endpoints because the setDeviceName action is exposed there for all platforms
    - Local interactive sign-in uses the MgGraphCommunity module to avoid the Graph SDK's mandatory WAM broker on Windows
#>

[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [Parameter(Mandatory = $false, HelpMessage = "Path to CSV file with DeviceName,NewName columns")]
    [string]$CsvPath,

    [Parameter(Mandatory = $false, HelpMessage = "CSV text with DeviceName,NewName columns, suitable for Azure Automation")]
    [string]$CsvContent = "",

    [Parameter(Mandatory = $false, HelpMessage = "Export results to CSV")]
    [ValidateSet("true", "false", "1", "0", '$true', '$false')]
    [string]$ExportToCsv,

    [Parameter(Mandatory = $false, HelpMessage = "Validate proposed renames without sending an action")]
    [ValidateSet("true", "false", "1", "0", '$true', '$false')]
    [string]$DryRun,

    [Parameter(Mandatory = $false, HelpMessage = "Output path for exports")]
    [string]$OutputPath = ".",

    [Parameter(Mandatory = $false, HelpMessage = "Force module installation without prompting")]
    [ValidateSet("true", "false", "1", "0", '$true', '$false')]
    [string]$ForceModuleInstall
)

# Normalize the local module-install override for Azure Automation parameter binding.
$forceModuleInstallRaw = [string]$ForceModuleInstall
Remove-Variable -Name ForceModuleInstall
if ([string]::IsNullOrWhiteSpace($forceModuleInstallRaw)) {
    $ForceModuleInstall = $false
}
elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("true", "1", '$true')) {
    $ForceModuleInstall = $true
}
elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("false", "0", '$false')) {
    $ForceModuleInstall = $false
}
else {
    throw "Parameter 'ForceModuleInstall' accepts only true, false, 1, 0, $true, or $false."
}

# Azure Automation supplies portal parameter values as strings. Normalize the
# public boolean parameters once so local and runbook execution use real booleans.
foreach ($runbookBooleanParameter in @('ExportToCsv', 'DryRun')) {
    $runbookBooleanRaw = [string](Get-Variable -Name $runbookBooleanParameter -ValueOnly)
    Remove-Variable -Name $runbookBooleanParameter

    if ([string]::IsNullOrWhiteSpace($runbookBooleanRaw)) {
        Set-Variable -Name $runbookBooleanParameter -Value $false
        continue
    }

    switch ($runbookBooleanRaw.Trim().ToLowerInvariant()) {
        { $_ -in @("true", "1", '$true') } {
            Set-Variable -Name $runbookBooleanParameter -Value $true
        }
        { $_ -in @("false", "0", '$false') } {
            Set-Variable -Name $runbookBooleanParameter -Value $false
        }
        default {
            throw "Parameter '$runbookBooleanParameter' accepts only true, false, 1, 0, $true, or $false."
        }
    }
}

# ============================================================================
# ENVIRONMENT DETECTION AND SETUP
# ============================================================================

function Initialize-RequiredModule {
    param(
        [string[]]$ModuleNames,
        [bool]$IsAutomationEnvironment,
        [bool]$ForceInstall = $false
    )

    foreach ($ModuleName in $ModuleNames) {
        Write-Verbose "Checking module: $ModuleName"

        $module = Get-Module -ListAvailable -Name $ModuleName | Select-Object -First 1

        if (-not $module) {
            if ($IsAutomationEnvironment) {
                throw "Module '$ModuleName' is not available in Azure Automation"
            }
            else {
                Write-Information "Module '$ModuleName' not found. Installing..." -InformationAction Continue

                if (-not $ForceInstall) {
                    $response = Read-Host "Install module '$ModuleName'? (Y/N)"
                    if ($response -notmatch '^[Yy]') {
                        throw "Module '$ModuleName' is required but installation was declined."
                    }
                }

                try {
                    $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
                    $scope = if ($isAdmin) { "AllUsers" } else { "CurrentUser" }

                    Install-Module -Name $ModuleName -Scope $scope -Force -AllowClobber -Repository PSGallery
                    Write-Information "✓ Successfully installed '$ModuleName'" -InformationAction Continue
                }
                catch {
                    throw "Failed to install module '$ModuleName': $($_.Exception.Message)"
                }
            }
        }

        Import-Module -Name $ModuleName -Force -ErrorAction Stop
    }
}

# Detect execution environment
$IsAzureAutomation = $null -ne $PSPrivateMetadata.JobId.Guid

# Initialize required modules
$RequiredModules = @("Microsoft.Graph.Authentication")

# MgGraphCommunity gives WAM-free interactive sign-in for local runs
if (-not $IsAzureAutomation) {
    $RequiredModules += "MgGraphCommunity"
}

try {
    Initialize-RequiredModule -ModuleNames $RequiredModules -IsAutomationEnvironment $IsAzureAutomation -ForceInstall $ForceModuleInstall
    Write-Verbose "✓ All required modules are available"
}
catch {
    Write-Error "Module initialization failed: $_"
    exit 1
}

# ============================================================================
# AUTHENTICATION
# ============================================================================

try {
    if ($IsAzureAutomation) {
        Write-Output "Connecting to Microsoft Graph using Managed Identity..."
        Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop
    }
    else {
        Write-Output "Connecting to Microsoft Graph..."
        $Scopes = @(
            "DeviceManagementManagedDevices.PrivilegedOperations.All",
            "DeviceManagementManagedDevices.Read.All"
        )
        Connect-MgGraphCommunity -Scopes $Scopes -NoWelcome -ErrorAction Stop
    }
    Write-Output "✓ Successfully connected to Microsoft Graph"
}
catch {
    Write-Error "Failed to connect to Microsoft Graph: $($_.Exception.Message)"
    exit 1
}

# ============================================================================
# HELPER FUNCTIONS
# ============================================================================

function Get-MgGraphAllPage {
    param(
        [string]$Uri,
        [int]$DelayMs = 100
    )

    $allResults = @()
    $nextLink = $Uri

    do {
        try {
            if ($allResults.Count -gt 0) {
                Start-Sleep -Milliseconds $DelayMs
            }

            $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET

            if ($null -ne $response.value) {
                $allResults += $response.value
            }
            else {
                $allResults += $response
            }

            $nextLink = $response.'@odata.nextLink'
        }
        catch {
            if ($_.Exception.Message -like "*429*") {
                Write-Information "Rate limit hit, waiting 60 seconds..." -InformationAction Continue
                Start-Sleep -Seconds 60
                continue
            }
            throw "Error fetching data: $($_.Exception.Message)"
        }
    } while ($nextLink)

    return $allResults
}

function Test-ValidComputerName {
    param([string]$Name)

    if ([string]::IsNullOrWhiteSpace($Name)) { return $false }
    if ($Name.Length -gt 15) { return $false }
    if ($Name -match '^[0-9]+$') { return $false }
    if ($Name -notmatch '^[A-Za-z0-9-]+$') { return $false }
    if ($Name.StartsWith("-") -or $Name.EndsWith("-")) { return $false }
    return $true
}

# ============================================================================
# MAIN SCRIPT LOGIC
# ============================================================================

try {
    $csvInputs = @(
        if (-not [string]::IsNullOrWhiteSpace($CsvPath)) { 'CsvPath' }
        if (-not [string]::IsNullOrWhiteSpace($CsvContent)) { 'CsvContent' }
    )
    if ($csvInputs.Count -ne 1) {
        throw "Specify exactly one CSV input: CsvPath or CsvContent."
    }

    if (-not [string]::IsNullOrWhiteSpace($CsvContent)) {
        $renameEntries = @($CsvContent | ConvertFrom-Csv -ErrorAction Stop)
        $csvSourceDescription = "runtime CSV content"
    }
    else {
        if (-not (Test-Path $CsvPath)) {
            throw "CSV file '$CsvPath' does not exist"
        }
        $renameEntries = @(Import-Csv -Path $CsvPath -ErrorAction Stop)
        $csvSourceDescription = $CsvPath
    }
    if ($renameEntries.Count -eq 0) {
        throw "CSV input contains no rows"
    }

    $csvColumns = $renameEntries[0].PSObject.Properties.Name
    if ($csvColumns -notcontains "DeviceName" -or $csvColumns -notcontains "NewName") {
        throw "CSV must contain the columns 'DeviceName' and 'NewName' (found: $($csvColumns -join ', '))"
    }

    Write-Output "✓ Loaded $($renameEntries.Count) rename entries from $csvSourceDescription"

    [System.Collections.Generic.List[Object]]$report = @()
    $renamed = 0
    $failed = 0
    $skipped = 0

    foreach ($entry in $renameEntries) {
        $currentName = $entry.DeviceName.Trim()
        $newName = $entry.NewName.Trim()

        # Validate before touching Graph so one bad row cannot burn an action
        if (-not (Test-ValidComputerName -Name $newName)) {
            Write-Warning "Skipping '$currentName': new name '$newName' is not a valid computer name (max 15 chars, letters/digits/hyphens, not all digits)"
            $report.Add([PSCustomObject]@{ DeviceName = $currentName; NewName = $newName; Result = "InvalidName" })
            $skipped++
            continue
        }

        $escapedName = $currentName -replace "'", "''"
        $found = Get-MgGraphAllPage -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=deviceName eq '$escapedName'&`$select=id,deviceName,operatingSystem"

        if (@($found).Count -eq 0) {
            Write-Warning "Skipping '$currentName': device not found in Intune"
            $report.Add([PSCustomObject]@{ DeviceName = $currentName; NewName = $newName; Result = "NotFound" })
            $skipped++
            continue
        }
        if (@($found).Count -gt 1) {
            Write-Warning "Skipping '$currentName': $(@($found).Count) devices share this name - rename manually to avoid hitting the wrong one"
            $report.Add([PSCustomObject]@{ DeviceName = $currentName; NewName = $newName; Result = "AmbiguousName" })
            $skipped++
            continue
        }

        $device = @($found)[0]

        if ($DryRun) {
            Write-Output "[DRY RUN] Would rename '$currentName' to '$newName'"
            $report.Add([PSCustomObject]@{ DeviceName = $currentName; NewName = $newName; Result = "DryRun" })
            continue
        }

        if ($PSCmdlet.ShouldProcess("$currentName -> $newName", "Send setDeviceName action")) {
            try {
                $body = @{ deviceName = $newName } | ConvertTo-Json
                Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($device.id)/setDeviceName" -Method POST -Body $body -ContentType "application/json"
                Write-Output "✓ Rename queued: $currentName -> $newName"
                $report.Add([PSCustomObject]@{ DeviceName = $currentName; NewName = $newName; Result = "Queued" })
                $renamed++
            }
            catch {
                Write-Warning "Failed to rename '$currentName': $($_.Exception.Message)"
                $report.Add([PSCustomObject]@{ DeviceName = $currentName; NewName = $newName; Result = "Failed" })
                $failed++
            }
        }
        else {
            $report.Add([PSCustomObject]@{ DeviceName = $currentName; NewName = $newName; Result = "WhatIf" })
        }
    }

    # Summary
    Write-Output "`n========================================"
    Write-Output "Rename Summary"
    Write-Output "========================================"
    Write-Output "CSV entries:     $($renameEntries.Count)"
    Write-Output "Renames queued:  $renamed"
    Write-Output "Failed:          $failed"
    Write-Output "Skipped:         $skipped"
    Write-Output "Note: renames apply at next device check-in; Windows devices need a restart to complete"
    Write-Output "========================================"

    # Export to CSV if requested
    if ($ExportToCsv) {
        if ($IsAzureAutomation) {
            Write-Output "CSV report follows in the Azure Automation job output:"
            $report | ConvertTo-Csv -NoTypeInformation | ForEach-Object { Write-Output $_ }
        }
        else {
            $timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"
            $csvOutPath = Join-Path $OutputPath "Device_Rename_Results_$timestamp.csv"
            $report | Export-Csv -Path $csvOutPath -NoTypeInformation -Encoding UTF8
            Write-Output "✓ CSV report saved: $csvOutPath"
        }
    }
}
catch {
    Write-Error "Script execution failed: $($_.Exception.Message)"
    exit 1
}
finally {
    try {
        $null = Disconnect-MgGraph
        Write-Output "✓ Disconnected from Microsoft Graph"
    }
    catch {
        Write-Verbose "Graph disconnection completed"
    }
}

# Copia sin cambios de https://github.com/ugurkocde/IntuneAutomation (commit 797bb2e).
# MIT License
#
# Copyright (c) 2025 Ugur Koc | Microsoft MVP
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

Relacionados