├── .dockerignore ├── azuretpl ├── constants.go ├── cache.go ├── jsonpath.go ├── azure.account.go ├── msgraph.misc.go ├── misc.go ├── azure.redis.go ├── models │ ├── opts.go │ ├── keyvault.go │ └── appconfig.go ├── azure.eventhub.go ├── azure.managedcluster.go ├── files.go ├── time.go ├── azure.mgmtgroup.go ├── azure.storageaccount.go ├── azure.resourcegraph.go ├── azure.rbac.go ├── summary.go ├── azure.subscription.go ├── msgraph.group.go ├── azure.resources.go ├── msgraph.user.go ├── cicd.go ├── msgraph.application.go ├── msgraph.serviceprincipal.go ├── azure.appconfig.go ├── azure.network.go ├── helm.go ├── azure.keyvault.go └── command.go ├── .gitignore ├── common.system.go ├── .github ├── actions │ ├── setup-runner │ │ └── action.yaml │ └── setup-go │ │ └── action.yaml ├── workflows │ ├── ci-docker.yaml │ ├── schedule-docker.yaml │ ├── release-docker.yaml │ ├── build-docker.yaml │ └── release-assets.yaml └── dependabot.yml ├── plugin.sh ├── plugins ├── azure-tpl │ ├── plugin.sh │ ├── plugin.yaml │ └── install.sh ├── azure-tpl-getter │ ├── plugin.sh │ ├── plugin.yaml │ └── install.sh └── azure-tpl-cli │ ├── plugin.yaml │ └── install.sh ├── common.logger.go ├── .golangci.yaml ├── example.tpl ├── Dockerfile ├── plugin.yaml ├── entrypoint.sh ├── .editorconfig ├── templatefile.go ├── install.sh ├── config └── opts.go ├── main.go ├── Makefile ├── go.mod ├── process.go ├── LICENSE ├── go.sum └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /helm-azure-tpl* 3 | /release-assets 4 | /tmp 5 | -------------------------------------------------------------------------------- /azuretpl/constants.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | const ( 4 | recursionMaxNums = 100 5 | ) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor/ 3 | /helm-azure-tpl* 4 | /development.* 5 | /release-assets 6 | /tmp 7 | -------------------------------------------------------------------------------- /common.system.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/webdevops/go-common/system" 5 | ) 6 | 7 | func initSystem() { 8 | system.AutoProcMemLimit(logger.Slog()) 9 | } 10 | -------------------------------------------------------------------------------- /azuretpl/cache.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/patrickmn/go-cache" 7 | ) 8 | 9 | var ( 10 | globalCache *cache.Cache 11 | ) 12 | 13 | func init() { 14 | globalCache = cache.New(15*time.Minute, 1*time.Minute) 15 | } 16 | -------------------------------------------------------------------------------- /.github/actions/setup-runner/action.yaml: -------------------------------------------------------------------------------- 1 | name: 'Setup-Runner' 2 | description: 'Setup runner' 3 | runs: 4 | using: composite 5 | steps: 6 | - name: Set Swap Space 7 | uses: pierotofy/set-swap-space@fc79b3f67fa8a838184ce84a674ca12238d2c761 8 | with: 9 | swap-size-gb: 12 10 | -------------------------------------------------------------------------------- /.github/workflows/ci-docker.yaml: -------------------------------------------------------------------------------- 1 | name: "ci/docker" 2 | 3 | on: [pull_request, workflow_dispatch] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | build: 11 | uses: ./.github/workflows/build-docker.yaml 12 | secrets: inherit 13 | with: 14 | publish: false 15 | -------------------------------------------------------------------------------- /.github/workflows/schedule-docker.yaml: -------------------------------------------------------------------------------- 1 | name: "schedule/docker" 2 | 3 | on: 4 | schedule: 5 | - cron: '45 6 * * 1' 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | schedule: 13 | uses: ./.github/workflows/build-docker.yaml 14 | secrets: inherit 15 | with: 16 | publish: true 17 | -------------------------------------------------------------------------------- /.github/actions/setup-go/action.yaml: -------------------------------------------------------------------------------- 1 | name: 'Setup-Go' 2 | description: 'Setup go with dependencies' 3 | runs: 4 | using: composite 5 | steps: 6 | 7 | - uses: actions/setup-go@v6 8 | with: 9 | go-version-file: 'go.mod' 10 | cache-dependency-path: "go.sum" 11 | check-latest: true 12 | 13 | - name: GoLang dependencies 14 | shell: bash 15 | run: | 16 | go mod vendor 17 | -------------------------------------------------------------------------------- /plugin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | println() { 6 | >&2 echo "$*" 7 | } 8 | 9 | if [[ -n "$GITHUB_ACTION" ]]; then 10 | println "::group::$4" 11 | else 12 | println " " 13 | fi 14 | 15 | println "executing azure-tpl for \"$4\":" 16 | "${HELM_PLUGIN_DIR}/helm-azure-tpl" apply --stdout "$4" 17 | EXIT_CODE="$?" 18 | 19 | if [[ -n "$GITHUB_ACTION" ]]; then 20 | println "::endgroup::" 21 | else 22 | println " " 23 | fi 24 | 25 | exit "$EXIT_CODE" 26 | -------------------------------------------------------------------------------- /plugins/azure-tpl/plugin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | println() { 6 | >&2 echo "$*" 7 | } 8 | 9 | if [[ -n "$GITHUB_ACTION" ]]; then 10 | println "::group::$4" 11 | else 12 | println " " 13 | fi 14 | 15 | println "executing azure-tpl for \"$4\":" 16 | "${HELM_PLUGIN_DIR}/helm-azure-tpl" apply --stdout "$4" 17 | EXIT_CODE="$?" 18 | 19 | if [[ -n "$GITHUB_ACTION" ]]; then 20 | println "::endgroup::" 21 | else 22 | println " " 23 | fi 24 | 25 | exit "$EXIT_CODE" 26 | -------------------------------------------------------------------------------- /plugins/azure-tpl-getter/plugin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | println() { 6 | >&2 echo "$*" 7 | } 8 | 9 | if [[ -n "$GITHUB_ACTION" ]]; then 10 | println "::group::$4" 11 | else 12 | println " " 13 | fi 14 | 15 | println "executing azure-tpl for \"$4\":" 16 | "${HELM_PLUGIN_DIR}/helm-azure-tpl" apply --stdout "$4" 17 | EXIT_CODE="$?" 18 | 19 | if [[ -n "$GITHUB_ACTION" ]]; then 20 | println "::endgroup::" 21 | else 22 | println " " 23 | fi 24 | 25 | exit "$EXIT_CODE" 26 | -------------------------------------------------------------------------------- /.github/workflows/release-docker.yaml: -------------------------------------------------------------------------------- 1 | name: "release/docker" 2 | 3 | on: 4 | workflow_dispatch: {} 5 | push: 6 | branches: 7 | - 'main' 8 | - 'feature-**' 9 | - 'bugfix-**' 10 | tags: 11 | - '*.*.*' 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | release: 19 | uses: ./.github/workflows/build-docker.yaml 20 | secrets: inherit 21 | with: 22 | publish: ${{ github.event_name != 'pull_request' }} 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | all-github-actions: 9 | patterns: [ "*" ] 10 | 11 | - package-ecosystem: "docker" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | groups: 16 | all-docker-versions: 17 | patterns: [ "*" ] 18 | 19 | - package-ecosystem: "gomod" 20 | directory: "/" 21 | schedule: 22 | interval: "weekly" 23 | groups: 24 | all-go-mod-patch-and-minor: 25 | patterns: [ "*" ] 26 | update-types: [ "patch", "minor" ] 27 | -------------------------------------------------------------------------------- /common.logger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/webdevops/go-common/log/slogger" 7 | ) 8 | 9 | var ( 10 | logger *slogger.Logger 11 | ) 12 | 13 | func initLogger() *slogger.Logger { 14 | loggerOpts := []slogger.LoggerOptionFunc{ 15 | slogger.WithLevelText(opts.Logger.Level), 16 | slogger.WithFormat(slogger.FormatMode(opts.Logger.Format)), 17 | slogger.WithSourceMode(slogger.SourceMode(opts.Logger.Source)), 18 | slogger.WithTime(opts.Logger.Time), 19 | slogger.WithColor(slogger.ColorMode(opts.Logger.Color)), 20 | } 21 | 22 | logger = slogger.NewCliLogger( 23 | os.Stderr, loggerOpts..., 24 | ) 25 | 26 | return logger 27 | } 28 | -------------------------------------------------------------------------------- /azuretpl/jsonpath.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/PaesslerAG/jsonpath" 7 | ) 8 | 9 | // jsonPath executes jsonPath query on an object and returns the result 10 | func (e *AzureTemplateExecutor) jsonPath(jsonPath string, v interface{}) (interface{}, error) { 11 | 12 | if v, enabled := e.lintResult(); enabled { 13 | // validate jsonpath 14 | _, err := jsonpath.Language().NewEvaluableWithContext(e.ctx, jsonPath) 15 | return v, err 16 | } 17 | 18 | ret, err := jsonpath.Get(jsonPath, v) 19 | if err != nil { 20 | return nil, fmt.Errorf(`unable to execute jsonpath '%v': %w`, jsonPath, err) 21 | } 22 | 23 | return ret, nil 24 | } 25 | -------------------------------------------------------------------------------- /azuretpl/azure.account.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | "os" 7 | "os/exec" 8 | ) 9 | 10 | func (e *AzureTemplateExecutor) azAccountInfo() (interface{}, error) { 11 | if e.azureCliAccountInfo == nil { 12 | cmd := exec.Command("az", "account", "show", "-o", "json") 13 | cmd.Stderr = os.Stderr 14 | 15 | accountInfo, err := cmd.Output() 16 | if err != nil { 17 | e.logger.Error(`unable to detect Azure TenantID via 'az account show'`, slog.Any("error", err)) 18 | os.Exit(1) 19 | } 20 | 21 | err = json.Unmarshal(accountInfo, &e.azureCliAccountInfo) 22 | if err != nil { 23 | e.logger.Error(`unable to parse 'az account show' output`, slog.Any("error", err)) 24 | os.Exit(1) 25 | } 26 | } 27 | return e.azureCliAccountInfo, nil 28 | } 29 | -------------------------------------------------------------------------------- /azuretpl/msgraph.misc.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/microsoft/kiota-abstractions-go/serialization" 7 | ) 8 | 9 | func (e *AzureTemplateExecutor) mgSerializeObject(resultObj serialization.Parsable) (obj interface{}, err error) { 10 | writer, err := e.msGraphClient().RequestAdapter().GetSerializationWriterFactory().GetSerializationWriter("application/json") 11 | if err != nil { 12 | return nil, err 13 | } 14 | 15 | err = writer.WriteObjectValue("", resultObj) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | serializedValue, err := writer.GetSerializedContent() 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | err = json.Unmarshal(serializedValue, &obj) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return 31 | } 32 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - asciicheck 5 | - bidichk 6 | - bodyclose 7 | - copyloopvar 8 | - errorlint 9 | - gomodguard 10 | - gosec 11 | settings: 12 | gomodguard: 13 | blocked: 14 | modules: 15 | - github.com/Azure/go-autorest/autorest/azure/auth: 16 | reason: deprecated 17 | gosec: 18 | confidence: low 19 | config: 20 | global: 21 | audit: true 22 | exclusions: 23 | generated: lax 24 | presets: 25 | - comments 26 | - common-false-positives 27 | - legacy 28 | - std-error-handling 29 | paths: 30 | - third_party$ 31 | - builtin$ 32 | - examples$ 33 | formatters: 34 | enable: 35 | - gofmt 36 | - goimports 37 | exclusions: 38 | generated: lax 39 | paths: 40 | - third_party$ 41 | - builtin$ 42 | - examples$ 43 | -------------------------------------------------------------------------------- /plugins/azure-tpl-cli/plugin.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | type: cli/v1 4 | name: "azure-tpl-cli" 5 | version: "0.63.9" 6 | runtime: subprocess 7 | config: 8 | usage: azure-tpl 9 | shortHelp: helm-azure-tpl is a helm plugin for Azure template functions. 10 | runtimeConfig: 11 | platformCommand: 12 | - os: windows 13 | command: >- 14 | cmd.exe /D /E:ON /V:ON /C !HELM_PLUGIN_DIR!\helm-azure-tpl.exe 15 | - os: linux 16 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl" 17 | - os: darwin 18 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl" 19 | - command: "$HELM_PLUGIN_DIR/helm-azure-tpl" 20 | platformHooks: 21 | install: 22 | - os: linux 23 | command: "$HELM_PLUGIN_DIR/install.sh" 24 | - os: darwin 25 | command: "$HELM_PLUGIN_DIR/install.sh" 26 | update: 27 | - os: linux 28 | command: "$HELM_PLUGIN_DIR/install.sh force" 29 | - os: darwin 30 | command: "$HELM_PLUGIN_DIR/install.sh force" 31 | -------------------------------------------------------------------------------- /azuretpl/misc.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | 7 | "github.com/webdevops/go-common/azuresdk/armclient" 8 | ) 9 | 10 | func escapeMsGraphFilter(val string) string { 11 | return strings.ReplaceAll(val, `''`, `\'`) 12 | } 13 | 14 | func generateCacheKey(val ...string) string { 15 | return strings.Join(val, ":") 16 | } 17 | 18 | func transformToInterface(obj interface{}) (interface{}, error) { 19 | data, err := json.Marshal(obj) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | var ret interface{} 25 | err = json.Unmarshal(data, &ret) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return ret, nil 31 | } 32 | 33 | func parseSubscriptionId(val string) (string, error) { 34 | val = strings.TrimSpace(val) 35 | if strings.HasPrefix(strings.ToLower(val), "/subscriptions/") { 36 | info, err := armclient.ParseResourceId(val) 37 | if err != nil { 38 | return "", err 39 | } 40 | 41 | return info.Subscription, nil 42 | } else { 43 | return val, nil 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /example.tpl: -------------------------------------------------------------------------------- 1 | {{ 2 | azureResource 3 | "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourcegroups/example-rg/providers/Microsoft.ContainerService/managedClusters/k8scluster" 4 | "2022-01-01" 5 | | toYaml | nindent 2 6 | }} 7 | 8 | 9 | {{ 10 | azureResource 11 | "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourcegroups/example-rg/providers/Microsoft.ContainerService/managedClusters/k8scluster" 12 | "2022-01-01" 13 | | jsonPath "$.properties.aadProfile" 14 | | toYaml | nindent 2 15 | }} 16 | 17 | {{ azureVirtualNetworkAddressPrefixes 18 | "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourcegroups/example-rg/providers/Microsoft.Network/virtualNetworks/k8s-vnet" 19 | }} 20 | 21 | 22 | {{ azureVirtualNetworkSubnetAddressPrefixes 23 | "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourcegroups/example-rg/providers/Microsoft.Network/virtualNetworks/k8s-vnet" 24 | "default2" 25 | | join "," 26 | }} 27 | 28 | {{ (azureKeyVaultSecret "https://examplevault.vault.azure.net/" "secretname").Value }} 29 | -------------------------------------------------------------------------------- /plugins/azure-tpl-getter/plugin.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | type: getter/v1 4 | name: "azure-tpl-getter" 5 | version: "0.63.9" 6 | runtime: subprocess 7 | config: 8 | protocols: 9 | - "azuretpl" 10 | - "azure-tpl" 11 | runtimeConfig: 12 | protocolCommands: 13 | - protocols: 14 | - "azuretpl" 15 | - "azure-tpl" 16 | platformCommand: 17 | - os: windows 18 | command: >- 19 | cmd.exe /D /E:ON /V:ON /C !HELM_PLUGIN_DIR!\plugin.cmd 20 | - os: linux 21 | command: "plugin.sh" 22 | - os: darwin 23 | command: "plugin.sh" 24 | - command: "plugin.sh" 25 | platformHooks: 26 | install: 27 | - os: linux 28 | command: "$HELM_PLUGIN_DIR/install.sh" 29 | - os: darwin 30 | command: "$HELM_PLUGIN_DIR/install.sh" 31 | - command: "$HELM_PLUGIN_DIR/install.sh" 32 | update: 33 | - os: linux 34 | command: "$HELM_PLUGIN_DIR/install.sh force" 35 | - os: darwin 36 | command: "$HELM_PLUGIN_DIR/install.sh force" 37 | - command: "$HELM_PLUGIN_DIR/install.sh force" 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ############################################# 2 | # Build 3 | ############################################# 4 | FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build 5 | 6 | RUN apk upgrade --no-cache --force 7 | RUN apk add --update build-base make git curl 8 | 9 | WORKDIR /go/src/github.com/webdevops/helm-azure-tpl 10 | 11 | COPY . . 12 | RUN make test 13 | 14 | # Compile 15 | ARG TARGETARCH 16 | RUN GOARCH=${TARGETARCH} make build 17 | RUN chmod +x entrypoint.sh 18 | 19 | ############################################# 20 | # Test 21 | ############################################# 22 | FROM gcr.io/distroless/static AS test 23 | USER 0:0 24 | WORKDIR /app 25 | COPY --from=build /go/src/github.com/webdevops/helm-azure-tpl/helm-azure-tpl . 26 | COPY --from=build /go/src/github.com/webdevops/helm-azure-tpl/entrypoint.sh . 27 | RUN ["./helm-azure-tpl", "--help"] 28 | 29 | ############################################# 30 | # final 31 | ############################################# 32 | FROM mcr.microsoft.com/azure-cli:latest AS final-azcli 33 | WORKDIR / 34 | COPY --from=test /app . 35 | USER 1000:1000 36 | ENTRYPOINT ["/entrypoint.sh"] 37 | -------------------------------------------------------------------------------- /plugin.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "azure-tpl" 3 | version: "0.63.9" 4 | usage: "applies azure information into your helm files" 5 | description: |- 6 | see https://github.com/webdevops/helm-azure-tpl/blob/main/README.md 7 | ignoreFlags: false 8 | platformCommand: 9 | ########################### 10 | # Linux 11 | - os: linux 12 | arch: amd64 13 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl" 14 | - os: linux 15 | arch: arm64 16 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl" 17 | ########################### 18 | # osx 19 | - os: darwin 20 | arch: amd64 21 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl" 22 | - os: darwin 23 | arch: arm64 24 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl" 25 | ########################### 26 | # Windows 27 | - os: windows 28 | arch: amd64 29 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl.exe" 30 | - os: windows 31 | arch: arm64 32 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl.exe" 33 | hooks: 34 | install: "bash $HELM_PLUGIN_DIR/install.sh" 35 | update: "bash $HELM_PLUGIN_DIR/install.sh force" 36 | downloaders: 37 | - command: "plugin.sh" 38 | protocols: 39 | - "azuretpl" 40 | - "azure-tpl" 41 | -------------------------------------------------------------------------------- /plugins/azure-tpl/plugin.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "azure-tpl" 3 | version: "0.63.9" 4 | usage: "applies azure information into your helm files" 5 | description: |- 6 | see https://github.com/webdevops/helm-azure-tpl/blob/main/README.md 7 | ignoreFlags: false 8 | platformCommand: 9 | ########################### 10 | # Linux 11 | - os: linux 12 | arch: amd64 13 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl" 14 | - os: linux 15 | arch: arm64 16 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl" 17 | ########################### 18 | # osx 19 | - os: darwin 20 | arch: amd64 21 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl" 22 | - os: darwin 23 | arch: arm64 24 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl" 25 | ########################### 26 | # Windows 27 | - os: windows 28 | arch: amd64 29 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl.exe" 30 | - os: windows 31 | arch: arm64 32 | command: "$HELM_PLUGIN_DIR/helm-azure-tpl.exe" 33 | hooks: 34 | install: "bash $HELM_PLUGIN_DIR/install.sh" 35 | update: "bash $HELM_PLUGIN_DIR/install.sh force" 36 | 37 | downloaders: 38 | - command: "plugin.sh" 39 | protocols: 40 | - "azuretpl" 41 | - "azure-tpl" 42 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o pipefail ## trace ERR through pipes 4 | set -o errtrace ## trace ERR through 'time command' and other functions 5 | set -o nounset ## set -u : exit the script if you try to use an uninitialised variable 6 | set -o errexit ## set -e : exit the script if any statement returns a non-true return value 7 | 8 | if [[ "$#" -ge 1 ]] && [[ "$1" == install* ]]; then 9 | if [[ "$#" -eq 2 ]]; then 10 | COMMAND="$1" 11 | TARGET_DIR="$2" 12 | case "$COMMAND" in 13 | "install"|"install.linux") 14 | echo "install helm-azure-tpl (linux) to $TARGET_DIR" 15 | if [ -d "$TARGET_DIR" ]; then 16 | cp -- /helm-azure-tpl "$TARGET_DIR/helm-azure-tpl" 17 | chmod +x "$TARGET_DIR/helm-azure-tpl" 18 | exit 0 19 | else 20 | >&2 echo "target directory \"$TARGET_DIR\" doesn't exists" 21 | exit 1 22 | fi 23 | ;; 24 | 25 | *) 26 | >&2 echo "failed to install helm-azure-tpl: \"$COMMAND\" is not a valid install command" 27 | exit 1 28 | ;; 29 | esac 30 | else 31 | >&2 echo "failed to install helm-azure-tpl: target path not specified as argument" 32 | exit 1 33 | fi 34 | fi 35 | 36 | exec /helm-azure-tpl "$@" 37 | -------------------------------------------------------------------------------- /azuretpl/azure.redis.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis" 8 | "github.com/webdevops/go-common/azuresdk/armclient" 9 | "github.com/webdevops/go-common/utils/to" 10 | ) 11 | 12 | // azRedisAccessKeys fetches accesskeys from redis cache 13 | func (e *AzureTemplateExecutor) azRedisAccessKeys(resourceID string) (interface{}, error) { 14 | e.logger.Info(`fetching Azure Redis accesskey`, slog.String("resourceID", resourceID)) 15 | 16 | if val, enabled := e.lintResult(); enabled { 17 | return val, nil 18 | } 19 | 20 | cacheKey := generateCacheKey(`azRedisAccessKeys`, resourceID) 21 | return e.cacheResult(cacheKey, func() (interface{}, error) { 22 | resourceInfo, err := armclient.ParseResourceId(resourceID) 23 | if err != nil { 24 | return nil, fmt.Errorf(`unable to parse Azure resourceID '%v': %w`, resourceID, err) 25 | } 26 | 27 | client, err := armredis.NewClient(resourceInfo.Subscription, e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | result, err := client.ListKeys(e.ctx, resourceInfo.ResourceGroup, resourceInfo.ResourceName, nil) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | val := []string{ 38 | to.String(result.PrimaryKey), 39 | to.String(result.SecondaryKey), 40 | } 41 | 42 | return transformToInterface(val) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /azuretpl/models/opts.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type ( 8 | Opts struct { 9 | Keyvault struct { 10 | ExpiryWarning time.Duration `long:"keyvault.expiry.warningduration" env:"AZURETPL_KEYVAULT_EXPIRY_WARNING_DURATION" description:"warn before soon expiring Azure KeyVault entries" default:"168h"` 11 | IgnoreExpiry bool `long:"keyvault.expiry.ignore" env:"AZURETPL_KEYVAULT_EXPIRY_IGNORE" description:"ignore expiry date of Azure KeyVault entries and don't fail'"` 12 | } 13 | 14 | ValuesFiles []string `long:"values" env:"AZURETPL_VALUES" env-delim:":" description:"path to yaml files for .Values"` 15 | JSONValues []string `long:"set-json" description:"set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2)"` 16 | Values []string `long:"set" description:"set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)"` 17 | StringValues []string `long:"set-string" description:"set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)"` 18 | FileValues []string `long:"set-file" description:"set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2)"` 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /azuretpl/azure.eventhub.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventhub/armeventhub" 8 | "github.com/webdevops/go-common/azuresdk/armclient" 9 | ) 10 | 11 | // azEventHubListByNamespace fetches list of Azure EventHubs by Namespace 12 | func (e *AzureTemplateExecutor) azEventHubListByNamespace(resourceID string) (interface{}, error) { 13 | e.logger.Info(`fetching EventHub list by namespace`, slog.String("resourceID", resourceID)) 14 | 15 | if val, enabled := e.lintResult(); enabled { 16 | return val, nil 17 | } 18 | 19 | cacheKey := generateCacheKey(`azEventHubListByNamespace`, resourceID) 20 | return e.cacheResult(cacheKey, func() (interface{}, error) { 21 | resourceInfo, err := armclient.ParseResourceId(resourceID) 22 | if err != nil { 23 | return nil, fmt.Errorf(`failed to parse resourceID "%v": %w`, resourceID, err) 24 | } 25 | 26 | client, err := armeventhub.NewEventHubsClient(resourceInfo.Subscription, e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 27 | if err != nil { 28 | return nil, fmt.Errorf(`failed to create EventHubsClient "%v": %w`, resourceID, err) 29 | } 30 | 31 | pager := client.NewListByNamespacePager(resourceInfo.ResourceGroup, resourceInfo.ResourceName, nil) 32 | 33 | ret := []*armeventhub.Eventhub{} 34 | for pager.More() { 35 | result, err := pager.NextPage(e.ctx) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | ret = append(ret, result.Value...) 41 | } 42 | 43 | return transformToInterface(ret) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /azuretpl/azure.managedcluster.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice" 8 | "github.com/webdevops/go-common/azuresdk/armclient" 9 | ) 10 | 11 | // azManagedClusterUserCredentials fetches user credentials object from managed cluster (AKS) 12 | func (e *AzureTemplateExecutor) azManagedClusterUserCredentials(resourceID string) (interface{}, error) { 13 | e.logger.Info(`fetching Azure ManagedCluster user credentials`, slog.String("resourceID", resourceID)) 14 | 15 | if val, enabled := e.lintResult(); enabled { 16 | return val, nil 17 | } 18 | cacheKey := generateCacheKey(`azManagedClusterUserCredentials`, resourceID) 19 | return e.cacheResult(cacheKey, func() (interface{}, error) { 20 | resourceInfo, err := armclient.ParseResourceId(resourceID) 21 | if err != nil { 22 | return nil, fmt.Errorf(`unable to parse Azure resourceID '%v': %w`, resourceID, err) 23 | } 24 | 25 | client, err := armcontainerservice.NewManagedClustersClient(resourceInfo.Subscription, e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 26 | if err != nil { 27 | return nil, fmt.Errorf(`failed to create ManagedCluster client for cluster "%v": %w`, resourceID, err) 28 | } 29 | 30 | userCreds, err := client.ListClusterUserCredentials(e.ctx, resourceInfo.ResourceGroup, resourceInfo.ResourceName, nil) 31 | if err != nil { 32 | return nil, fmt.Errorf(`failed to fetch ManagedCluster user credentials for cluster "%v": %w`, resourceID, err) 33 | } 34 | 35 | return transformToInterface(userCreds) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | end_of_line = lf 10 | insert_final_newline = true 11 | indent_style = space 12 | indent_size = 4 13 | 14 | [Makefile] 15 | indent_style = tab 16 | 17 | [{*.yml,*.yaml}] 18 | indent_size = 2 19 | 20 | [*.conf] 21 | indent_size = 2 22 | 23 | [*.go] 24 | indent_size = 4 25 | indent_style = tab 26 | ij_continuation_indent_size = 4 27 | ij_go_GROUP_CURRENT_PROJECT_IMPORTS = true 28 | ij_go_add_leading_space_to_comments = true 29 | ij_go_add_parentheses_for_single_import = true 30 | ij_go_call_parameters_new_line_after_left_paren = true 31 | ij_go_call_parameters_right_paren_on_new_line = true 32 | ij_go_call_parameters_wrap = off 33 | ij_go_fill_paragraph_width = 80 34 | ij_go_group_stdlib_imports = true 35 | ij_go_import_sorting = goimports 36 | ij_go_keep_indents_on_empty_lines = false 37 | ij_go_local_group_mode = project 38 | ij_go_move_all_imports_in_one_declaration = true 39 | ij_go_move_all_stdlib_imports_in_one_group = true 40 | ij_go_remove_redundant_import_aliases = false 41 | ij_go_run_go_fmt_on_reformat = true 42 | ij_go_use_back_quotes_for_imports = false 43 | ij_go_wrap_comp_lit = off 44 | ij_go_wrap_comp_lit_newline_after_lbrace = true 45 | ij_go_wrap_comp_lit_newline_before_rbrace = true 46 | ij_go_wrap_func_params = off 47 | ij_go_wrap_func_params_newline_after_lparen = true 48 | ij_go_wrap_func_params_newline_before_rparen = true 49 | ij_go_wrap_func_result = off 50 | ij_go_wrap_func_result_newline_after_lparen = true 51 | ij_go_wrap_func_result_newline_before_rparen = true 52 | -------------------------------------------------------------------------------- /azuretpl/files.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | func (e *AzureTemplateExecutor) fileMakePathAbs(path string) string { 11 | path = filepath.Clean(path) 12 | 13 | if filepath.IsAbs(path) { 14 | path = fmt.Sprintf( 15 | "%s/%s", 16 | e.TemplateRootPath, 17 | strings.TrimLeft(path, string(os.PathSeparator)), 18 | ) 19 | } else { 20 | path = fmt.Sprintf( 21 | "%s/%s", 22 | e.TemplateRelPath, 23 | strings.TrimLeft(path, string(os.PathSeparator)), 24 | ) 25 | } 26 | 27 | return path 28 | } 29 | 30 | func (e *AzureTemplateExecutor) filesGet(path string) (string, error) { 31 | sourcePath := e.fileMakePathAbs(path) 32 | 33 | content, err := os.ReadFile(sourcePath) 34 | if err != nil { 35 | return "", fmt.Errorf(`unable to read file: %w`, err) 36 | } 37 | 38 | return string(content), nil 39 | } 40 | 41 | func (e *AzureTemplateExecutor) filesGlob(pattern string) (interface{}, error) { 42 | pattern = e.fileMakePathAbs(pattern) 43 | matches, err := filepath.Glob(pattern) 44 | if err != nil { 45 | return nil, fmt.Errorf( 46 | `failed to parse glob pattern '%v': %w`, 47 | pattern, 48 | err, 49 | ) 50 | } 51 | 52 | var ret []string 53 | for _, path := range matches { 54 | fileInfo, err := os.Stat(path) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | if !fileInfo.IsDir() { 60 | // make path relative 61 | path = fmt.Sprintf(".%s%s", string(os.PathSeparator), strings.TrimLeft(strings.TrimPrefix(path, e.TemplateRootPath), string(os.PathSeparator))) 62 | ret = append(ret, path) 63 | } 64 | } 65 | 66 | return ret, err 67 | } 68 | -------------------------------------------------------------------------------- /azuretpl/models/keyvault.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" 5 | "github.com/webdevops/go-common/utils/to" 6 | ) 7 | 8 | type ( 9 | AzSecret struct { 10 | // The secret management attributes. 11 | Attributes *azsecrets.SecretAttributes `json:"attributes"` 12 | 13 | // The content type of the secret. 14 | ContentType *string `json:"contentType"` 15 | 16 | // The secret id. 17 | ID string `json:"id"` 18 | 19 | // Application specific metadata in the form of key-value pairs. 20 | Tags map[string]*string `json:"tags"` 21 | 22 | // The secret value. 23 | Value *string `json:"value"` 24 | 25 | Managed bool `json:"managed"` 26 | 27 | Version string `json:"version" yaml:"version"` 28 | Name string `json:"name" yaml:"name"` 29 | } 30 | ) 31 | 32 | func NewAzSecretItem(secret azsecrets.Secret) *AzSecret { 33 | return &AzSecret{ 34 | Attributes: secret.Attributes, 35 | ContentType: secret.ContentType, 36 | ID: string(*secret.ID), 37 | Tags: secret.Tags, 38 | Value: secret.Value, 39 | Managed: to.Bool(secret.Managed), 40 | Version: secret.ID.Version(), 41 | Name: secret.ID.Name(), 42 | } 43 | } 44 | 45 | func NewAzSecretItemFromSecretproperties(secret azsecrets.SecretProperties) *AzSecret { 46 | return &AzSecret{ 47 | Attributes: secret.Attributes, 48 | ContentType: secret.ContentType, 49 | ID: string(*secret.ID), 50 | Tags: secret.Tags, 51 | Value: nil, 52 | Managed: to.Bool(secret.Managed), 53 | Version: secret.ID.Version(), 54 | Name: secret.ID.Name(), 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /azuretpl/time.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | // fromUnixtime converts unixtime to time.Time object 10 | func fromUnixtime(val interface{}) (time.Time, error) { 11 | var timestamp int64 12 | switch v := val.(type) { 13 | // int 14 | case *int: 15 | if v == nil { 16 | return time.Unix(0, 0), fmt.Errorf(`cannot convert nil to time`) 17 | } 18 | timestamp = int64(*v) 19 | case int: 20 | timestamp = int64(v) 21 | 22 | // int64 23 | case *int64: 24 | if v == nil { 25 | return time.Unix(0, 0), fmt.Errorf(`cannot convert nil to time`) 26 | } 27 | timestamp = *v 28 | case int64: 29 | timestamp = int64(v) 30 | 31 | // float32 32 | case *float32: 33 | if v == nil { 34 | return time.Unix(0, 0), fmt.Errorf(`cannot convert nil to time`) 35 | } 36 | timestamp = int64(*v) 37 | case float32: 38 | timestamp = int64(v) 39 | 40 | // float64 41 | case *float64: 42 | if v == nil { 43 | return time.Unix(0, 0), fmt.Errorf(`cannot convert nil to time`) 44 | } 45 | timestamp = int64(*v) 46 | case float64: 47 | timestamp = int64(v) 48 | 49 | // string 50 | case *string: 51 | if v == nil { 52 | return time.Unix(0, 0), fmt.Errorf(`cannot convert nil to time`) 53 | } 54 | var err error 55 | timestamp, err = strconv.ParseInt(*v, 10, 64) 56 | if err != nil { 57 | return time.Unix(0, 0), err 58 | } 59 | case string: 60 | var err error 61 | timestamp, err = strconv.ParseInt(v, 10, 64) 62 | if err != nil { 63 | return time.Unix(0, 0), err 64 | } 65 | 66 | // time.Time 67 | case *time.Time: 68 | if v != nil { 69 | return *v, nil 70 | } 71 | return time.Unix(0, 0), fmt.Errorf(`cannot convert nil to time`) 72 | case time.Time: 73 | return v, nil 74 | 75 | // default 76 | default: 77 | return time.Unix(0, 0), fmt.Errorf(`invalid unixtimestamp "%v" defined, must be int or float`, val) 78 | } 79 | 80 | return time.Unix(timestamp, 0), nil 81 | } 82 | 83 | // toRFC3339 converts time.Time object to RFC 3339 string 84 | func toRFC3339(t time.Time) string { 85 | return t.Format(time.RFC3339) 86 | } 87 | -------------------------------------------------------------------------------- /templatefile.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/webdevops/go-common/log/slogger" 12 | 13 | "github.com/webdevops/helm-azure-tpl/azuretpl" 14 | ) 15 | 16 | type ( 17 | TemplateFile struct { 18 | Context context.Context 19 | SourceFile string 20 | TargetFile string 21 | TemplateBaseDir string 22 | Logger *slogger.Logger 23 | } 24 | ) 25 | 26 | func (f *TemplateFile) Lint() { 27 | var buf strings.Builder 28 | f.Logger.Info(`linting file`) 29 | f.parse(&buf) 30 | f.Logger.Info(`file successfully linted`) 31 | } 32 | 33 | func (f *TemplateFile) Apply() { 34 | var buf strings.Builder 35 | f.Logger.Info(`process file`) 36 | f.parse(&buf) 37 | 38 | if opts.Debug { 39 | fmt.Fprintln(os.Stderr) 40 | fmt.Fprintln(os.Stderr, strings.Repeat("-", TermColumns)) 41 | fmt.Fprintf(os.Stderr, "--- %v\n", f.TargetFile) 42 | fmt.Fprintln(os.Stderr, strings.Repeat("-", TermColumns)) 43 | fmt.Fprintln(os.Stderr, buf.String()) 44 | } 45 | 46 | if opts.Stdout { 47 | fmt.Println("--- # src: " + f.SourceFile) 48 | fmt.Println(buf.String()) 49 | fmt.Println() 50 | return 51 | } 52 | 53 | if !opts.DryRun { 54 | f.write(&buf) 55 | } else { 56 | f.Logger.Warn(`not writing file, DRY RUN active`) 57 | } 58 | } 59 | 60 | func (f *TemplateFile) parse(buf *strings.Builder) { 61 | ctx := f.Context 62 | contextLogger := f.Logger 63 | 64 | azureTemplate := azuretpl.New(ctx, opts.AzureTpl, contextLogger) 65 | azureTemplate.SetUserAgent(UserAgent + gitTag) 66 | azureTemplate.SetLintMode(lintMode) 67 | azureTemplate.SetTemplateRootPath(f.TemplateBaseDir) 68 | azureTemplate.SetTemplateRelPath(filepath.Dir(f.SourceFile)) 69 | err := azureTemplate.Parse(f.SourceFile, templateData, buf) 70 | if err != nil { 71 | f.Logger.Error(err.Error()) 72 | os.Exit(1) 73 | } 74 | } 75 | 76 | func (f *TemplateFile) write(buf *strings.Builder) { 77 | f.Logger.Info(`writing file`, slog.String("path", f.TargetFile)) 78 | err := os.WriteFile(f.TargetFile, []byte(buf.String()), 0600) 79 | if err != nil { 80 | f.Logger.Error(`unable to write target file`, slog.String("path", f.TargetFile), slog.Any("error", err.Error())) 81 | os.Exit(1) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /azuretpl/azure.mgmtgroup.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "strings" 7 | 8 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups" 9 | "github.com/webdevops/go-common/utils/to" 10 | ) 11 | 12 | // azManagementGroup fetches Azure ManagementGroup 13 | func (e *AzureTemplateExecutor) azManagementGroup(groupID string) (interface{}, error) { 14 | e.logger.Info(`fetching Azure ManagementGroup`, slog.String("mgmtgroup", groupID)) 15 | 16 | if val, enabled := e.lintResult(); enabled { 17 | return val, nil 18 | } 19 | 20 | cacheKey := generateCacheKey(`azManagementGroup`, groupID) 21 | return e.cacheResult(cacheKey, func() (interface{}, error) { 22 | client, err := armmanagementgroups.NewClient(e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 23 | if err != nil { 24 | return nil, fmt.Errorf(`failed to create ManagementGroup "%v": %w`, groupID, err) 25 | } 26 | 27 | managementGroup, err := client.Get(e.ctx, groupID, nil) 28 | if err != nil { 29 | return nil, fmt.Errorf(`failed to fetch ManagementGroup "%v": %w`, groupID, err) 30 | } 31 | 32 | return transformToInterface(managementGroup) 33 | }) 34 | } 35 | 36 | // azManagementGroupSubscriptionList fetches list of Azure Subscriptions under Azure ManagementGroup 37 | func (e *AzureTemplateExecutor) azManagementGroupSubscriptionList(groupID string) (interface{}, error) { 38 | e.logger.Info(`fetching subscriptions from Azure ManagementGroup`, slog.String("mgmtgroup", groupID)) 39 | 40 | if val, enabled := e.lintResult(); enabled { 41 | return val, nil 42 | } 43 | 44 | cacheKey := generateCacheKey(`azManagementGroupSubscriptionList`, groupID) 45 | return e.cacheResult(cacheKey, func() (interface{}, error) { 46 | client, err := armmanagementgroups.NewClient(e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 47 | if err != nil { 48 | return nil, fmt.Errorf(`failed to create ManagementGroup "%v": %w`, groupID, err) 49 | } 50 | 51 | pager := client.NewGetDescendantsPager(groupID, nil) 52 | ret := []interface{}{} 53 | for pager.More() { 54 | result, err := pager.NextPage(e.ctx) 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | for _, resource := range result.Value { 60 | if strings.EqualFold(to.String(resource.Type), "Microsoft.Management/managementGroups/subscriptions") { 61 | ret = append(ret, resource) 62 | } 63 | } 64 | } 65 | return transformToInterface(ret) 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /azuretpl/models/appconfig.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/Azure/azure-sdk-for-go/sdk/azcore" 7 | "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" 8 | "github.com/webdevops/go-common/utils/to" 9 | ) 10 | 11 | type ( 12 | AzAppconfigSetting struct { 13 | // The primary identifier of the configuration setting. 14 | // A Key is used together with a Label to uniquely identify a configuration setting. 15 | Key *string `json:"key"` 16 | 17 | // The configuration setting's value. 18 | Value *string `json:"value"` 19 | 20 | // A value used to group configuration settings. 21 | // A Label is used together with a Key to uniquely identify a configuration setting. 22 | Label *string `json:"label"` 23 | 24 | // The content type of the configuration setting's value. 25 | // Providing a proper content-type can enable transformations of values when they are retrieved by applications. 26 | ContentType *string `json:"contentType"` 27 | 28 | // An ETag indicating the state of a configuration setting within a configuration store. 29 | ETag *azcore.ETag `json:"eTag"` 30 | 31 | // A dictionary of tags used to assign additional properties to a configuration setting. 32 | // These can be used to indicate how a configuration setting may be applied. 33 | Tags map[string]string `json:"tags"` 34 | 35 | // The last time a modifying operation was performed on the given configuration setting. 36 | LastModified *time.Time `json:"lastModified"` 37 | 38 | // A value indicating whether the configuration setting is read only. 39 | // A read only configuration setting may not be modified until it is made writable. 40 | IsReadOnly bool `json:"isReadOnly"` 41 | 42 | // Sync token for the Azure App Configuration client, corresponding to the current state of the client. 43 | SyncToken *string `json:"syncToken"` 44 | } 45 | ) 46 | 47 | func NewAzAppconfigSetting(setting azappconfig.Setting) *AzAppconfigSetting { 48 | return &AzAppconfigSetting{ 49 | Key: setting.Key, 50 | Value: setting.Value, 51 | Label: setting.Label, 52 | ContentType: setting.ContentType, 53 | ETag: setting.ETag, 54 | Tags: setting.Tags, 55 | LastModified: setting.LastModified, 56 | IsReadOnly: to.Bool(setting.IsReadOnly), 57 | } 58 | } 59 | 60 | func NewAzAppconfigSettingFromReponse(setting azappconfig.GetSettingResponse) *AzAppconfigSetting { 61 | ret := NewAzAppconfigSetting(setting.Setting) 62 | ret.SyncToken = to.StringPtr(string(setting.SyncToken)) 63 | return ret 64 | } 65 | -------------------------------------------------------------------------------- /azuretpl/azure.storageaccount.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | 8 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage" 9 | "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" 10 | "github.com/webdevops/go-common/azuresdk/armclient" 11 | ) 12 | 13 | // azStorageAccountAccessKeys fetches container blob from StorageAccount 14 | func (e *AzureTemplateExecutor) azStorageAccountAccessKeys(resourceID string) (interface{}, error) { 15 | e.logger.Info(`fetching Azure StorageAccount accesskey`, slog.String("resourceID", resourceID)) 16 | 17 | if val, enabled := e.lintResult(); enabled { 18 | return val, nil 19 | } 20 | 21 | cacheKey := generateCacheKey(`azStorageAccountAccessKeys`, resourceID) 22 | return e.cacheResult(cacheKey, func() (interface{}, error) { 23 | resourceInfo, err := armclient.ParseResourceId(resourceID) 24 | if err != nil { 25 | return nil, fmt.Errorf(`unable to parse Azure resourceID '%v': %w`, resourceID, err) 26 | } 27 | 28 | client, err := armstorage.NewAccountsClient(resourceInfo.Subscription, e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | result, err := client.ListKeys(e.ctx, resourceInfo.ResourceGroup, resourceInfo.ResourceName, nil) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return transformToInterface(result.Keys) 39 | }) 40 | } 41 | 42 | // azStorageAccountContainerBlob fetches container blob from StorageAccount 43 | func (e *AzureTemplateExecutor) azStorageAccountContainerBlob(containerBlobUrl string) (interface{}, error) { 44 | e.logger.Info(`fetching Azure StorageAccount container blob`, slog.String("containerBlobUrl", containerBlobUrl)) 45 | 46 | if val, enabled := e.lintResult(); enabled { 47 | return val, nil 48 | } 49 | 50 | pathUrl, err := azblob.ParseURL(containerBlobUrl) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | cacheKey := generateCacheKey(`azStorageAccountContainerBlob`, containerBlobUrl) 56 | return e.cacheResult(cacheKey, func() (interface{}, error) { 57 | azblobOpts := azblob.ClientOptions{ClientOptions: *e.azureClient().NewAzCoreClientOptions()} 58 | 59 | storageAccountUrl := fmt.Sprintf("%s://%s", pathUrl.Scheme, pathUrl.Host) 60 | client, err := azblob.NewClient(storageAccountUrl, e.azureClient().GetCred(), &azblobOpts) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | response, err := client.DownloadStream(e.ctx, pathUrl.ContainerName, pathUrl.BlobName, nil) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | if content, err := io.ReadAll(response.Body); err == nil { 71 | return string(content), nil 72 | } else { 73 | return nil, err 74 | } 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /azuretpl/azure.resourcegraph.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "strings" 7 | 8 | "github.com/webdevops/go-common/azuresdk/armclient" 9 | ) 10 | 11 | // azResourceGraphQuery executes ResourceGraph query and returns result 12 | func (e *AzureTemplateExecutor) azResourceGraphQuery(scope interface{}, query string) (interface{}, error) { 13 | resourceGraphOptions := armclient.ResourceGraphOptions{} 14 | 15 | // make query more readable in outputs 16 | query = strings.TrimSpace(query) 17 | 18 | scopeList := []string{} 19 | 20 | switch v := scope.(type) { 21 | case string: 22 | for _, val := range strings.Split(v, ",") { 23 | scopeList = append(scopeList, val) 24 | 25 | err := parseResourceGraphScope(val, &resourceGraphOptions) 26 | if err != nil { 27 | panic(err) 28 | } 29 | } 30 | case []string: 31 | scopeList = v 32 | 33 | for _, val := range v { 34 | err := parseResourceGraphScope(val, &resourceGraphOptions) 35 | if err != nil { 36 | panic(err) 37 | } 38 | } 39 | default: 40 | return nil, fmt.Errorf(`invalid scope type, expected string or string array, got "%v"`, v) 41 | } 42 | 43 | if len(resourceGraphOptions.ManagementGroups) == 0 && len(resourceGraphOptions.Subscriptions) == 0 { 44 | return nil, fmt.Errorf(`{{azResourceGraphQuery}} needs at least one subscription ID or managementGroup ID`) 45 | } 46 | 47 | if val, enabled := e.lintResult(); enabled { 48 | return val, nil 49 | } 50 | 51 | e.logger.Info(`executing ResourceGraph query '%v' for scopes '%v'`, slog.String("query", query), slog.Any("scopes", scopeList)) 52 | 53 | cacheKey := generateCacheKey(`azResourceGraphQuery`, query, strings.Join(scopeList, ",")) 54 | return e.cacheResult(cacheKey, func() (interface{}, error) { 55 | result, err := e.azureClient().ExecuteResourceGraphQuery(e.ctx, query, resourceGraphOptions) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | return transformToInterface(result) 61 | }) 62 | } 63 | 64 | func parseResourceGraphScope(scope string, resourceGraphOptions *armclient.ResourceGraphOptions) error { 65 | scope = strings.TrimSpace(scope) 66 | if strings.HasPrefix(strings.ToLower(scope), "/providers/microsoft.management/managementgroups/") { 67 | // seems to be a mgmtgroup id 68 | managementGroupId := strings.TrimPrefix(strings.ToLower(scope), "/providers/microsoft.management/managementgroups/") 69 | resourceGraphOptions.ManagementGroups = append(resourceGraphOptions.Subscriptions, managementGroupId) 70 | } else { 71 | // might be a subscription id 72 | val, err := parseSubscriptionId(scope) 73 | if err != nil { 74 | return err 75 | } 76 | resourceGraphOptions.Subscriptions = append(resourceGraphOptions.Subscriptions, val) 77 | } 78 | 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /.github/workflows/build-docker.yaml: -------------------------------------------------------------------------------- 1 | name: build/docker 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | publish: 7 | required: true 8 | type: boolean 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v6 15 | 16 | - name: Setup runner 17 | uses: ./.github/actions/setup-runner 18 | 19 | - name: Setup go 20 | uses: ./.github/actions/setup-go 21 | 22 | - name: Run Golangci lint 23 | uses: golangci/golangci-lint-action@v9 24 | with: 25 | version: latest 26 | args: --print-resources-usage 27 | 28 | build: 29 | name: "build ${{ matrix.Dockerfile }}:${{ matrix.target }}" 30 | needs: lint 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | include: 35 | - Dockerfile: Dockerfile 36 | target: "final-azcli" 37 | suffix: "" 38 | latest: "auto" 39 | 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v6 43 | 44 | - name: Setup runner 45 | uses: ./.github/actions/setup-runner 46 | 47 | - name: Setup go 48 | uses: ./.github/actions/setup-go 49 | 50 | - name: Docker meta 51 | id: docker_meta 52 | uses: docker/metadata-action@v5 53 | with: 54 | images: | 55 | ${{ github.repository }} 56 | quay.io/${{ github.repository }} 57 | labels: | 58 | io.artifacthub.package.readme-url=https://raw.githubusercontent.com/${{ github.repository }}/${{ github.event.repository.default_branch }}/README.md 59 | flavor: | 60 | latest=${{ matrix.latest }} 61 | suffix=${{ matrix.suffix }} 62 | 63 | - name: Set up QEMU 64 | uses: docker/setup-qemu-action@v3 65 | 66 | - name: Set up Docker Buildx 67 | uses: docker/setup-buildx-action@v3 68 | 69 | - name: Login to DockerHub 70 | uses: docker/login-action@v3 71 | if: ${{ inputs.publish }} 72 | with: 73 | username: ${{ secrets.DOCKERHUB_USERNAME }} 74 | password: ${{ secrets.DOCKERHUB_TOKEN }} 75 | 76 | - name: Login to Quay 77 | uses: docker/login-action@v3 78 | if: ${{ inputs.publish }} 79 | with: 80 | registry: quay.io 81 | username: ${{ secrets.QUAY_USERNAME }} 82 | password: ${{ secrets.QUAY_TOKEN }} 83 | 84 | - name: ${{ inputs.publish && 'Build and push' || 'Build' }} 85 | uses: docker/build-push-action@v6 86 | with: 87 | context: . 88 | file: ./${{ matrix.Dockerfile }} 89 | target: ${{ matrix.target }} 90 | platforms: linux/amd64,linux/arm64 91 | push: ${{ inputs.publish }} 92 | tags: ${{ steps.docker_meta.outputs.tags }} 93 | labels: ${{ steps.docker_meta.outputs.labels }} 94 | cache-from: type=gha 95 | cache-to: type=gha,mode=max 96 | -------------------------------------------------------------------------------- /azuretpl/azure.rbac.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | armauthorization "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2" 8 | "github.com/webdevops/go-common/utils/to" 9 | ) 10 | 11 | // azRoleDefinition fetches Azure RoleDefinition by roleName 12 | func (e *AzureTemplateExecutor) azRoleDefinition(scope string, roleName string) (interface{}, error) { 13 | e.logger.Info(`fetching Azure RoleDefinition`, slog.String("scope", scope), slog.String("role", roleName)) 14 | 15 | if val, enabled := e.lintResult(); enabled { 16 | return val, nil 17 | } 18 | 19 | cacheKey := generateCacheKey(`azRoleDefinition`, scope, roleName) 20 | return e.cacheResult(cacheKey, func() (interface{}, error) { 21 | filter := fmt.Sprintf( 22 | `roleName eq '%s'`, 23 | roleName, 24 | ) 25 | 26 | result, err := e.fetchAzureRoleDefinitions(scope, filter) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | if len(result) == 0 { 32 | return nil, fmt.Errorf(`no Azure RoleDefinition with roleName '%v' for scope '%v' found`, roleName, scope) 33 | } 34 | 35 | if len(result) > 1 { 36 | return nil, fmt.Errorf(`multiple Azure RoleDefinitions for roleName '%v' for scope '%v' found`, roleName, scope) 37 | } 38 | 39 | return transformToInterface(result[0]) 40 | }) 41 | } 42 | 43 | // azRoleDefinitionList fetches list of roleDefinitions using $filter 44 | func (e *AzureTemplateExecutor) azRoleDefinitionList(scope string, filter ...string) (interface{}, error) { 45 | var roleDefinitionFilter string 46 | 47 | if len(filter) == 1 { 48 | roleDefinitionFilter = filter[0] 49 | } 50 | 51 | e.logger.Info(`fetching Azure RoleDefinitions`, slog.String("scope", scope), slog.String("filter", roleDefinitionFilter)) 52 | 53 | if val, enabled := e.lintResult(); enabled { 54 | return val, nil 55 | } 56 | 57 | cacheKey := generateCacheKey(`azRoleDefinitionList`, scope, roleDefinitionFilter) 58 | return e.cacheResult(cacheKey, func() (interface{}, error) { 59 | result, err := e.fetchAzureRoleDefinitions(scope, roleDefinitionFilter) 60 | if err != nil { 61 | return nil, err 62 | } 63 | return transformToInterface(result) 64 | }) 65 | } 66 | 67 | func (e *AzureTemplateExecutor) fetchAzureRoleDefinitions(scope string, filter string) ([]armauthorization.RoleDefinition, error) { 68 | client, err := armauthorization.NewRoleDefinitionsClient(e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | listOpts := armauthorization.RoleDefinitionsClientListOptions{ 74 | Filter: to.StringPtr(filter), 75 | } 76 | pager := client.NewListPager(scope, &listOpts) 77 | 78 | list := []armauthorization.RoleDefinition{} 79 | for pager.More() { 80 | result, err := pager.NextPage(e.ctx) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | for _, roleDefinition := range result.Value { 86 | list = append(list, *roleDefinition) 87 | } 88 | } 89 | 90 | return list, nil 91 | } 92 | -------------------------------------------------------------------------------- /azuretpl/summary.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" 11 | "github.com/webdevops/go-common/log/slogger" 12 | "github.com/webdevops/go-common/utils/to" 13 | 14 | "github.com/webdevops/helm-azure-tpl/config" 15 | ) 16 | 17 | const ( 18 | SummaryHeader = "# helm-azure-tpl summary\n" 19 | SummaryValueNotSet = "" 20 | ) 21 | 22 | var ( 23 | summary map[string][]string = map[string][]string{} 24 | ) 25 | 26 | func (e *AzureTemplateExecutor) addSummaryLine(section, val string) { 27 | if _, ok := summary[section]; !ok { 28 | summary[section] = []string{} 29 | } 30 | 31 | summary[section] = append(summary[section], val) 32 | } 33 | 34 | func (e *AzureTemplateExecutor) addSummaryKeyvaultSecret(vaultUrl string, secret azsecrets.GetSecretResponse) { 35 | section := "Azure Keyvault Secrets" 36 | if _, ok := summary[section]; !ok { 37 | summary[section] = []string{ 38 | "| KeyVault | Secret | Version | ContentType | Expiry |", 39 | "|----------|--------|---------|-------------|--------|", 40 | } 41 | } 42 | 43 | expiryDate := SummaryValueNotSet 44 | if secret.Attributes != nil && secret.Attributes.Expires != nil { 45 | expiryDate = secret.Attributes.Expires.Format(time.RFC3339) 46 | } 47 | 48 | contentType := to.String(secret.ContentType) 49 | if contentType == "" { 50 | contentType = SummaryValueNotSet 51 | } 52 | 53 | val := fmt.Sprintf( 54 | "| %s | %s | %s | %s | %s |", 55 | vaultUrl, 56 | secret.ID.Name(), 57 | secret.ID.Version(), 58 | contentType, 59 | expiryDate, 60 | ) 61 | 62 | summary[section] = append(summary[section], val) 63 | } 64 | 65 | func buildSummary(opts config.Opts) string { 66 | output := []string{SummaryHeader} 67 | 68 | output = append(output, "templates:\n") 69 | for _, file := range opts.Args.Files { 70 | output = append(output, fmt.Sprintf("- %s", file)) 71 | } 72 | 73 | for section, rows := range summary { 74 | output = append(output, fmt.Sprintf("\n### %s\n", section)) 75 | output = append(output, strings.Join(rows, "\n")) 76 | } 77 | 78 | return "\n" + strings.Join(output, "\n") + "\n" 79 | } 80 | 81 | func PostSummary(logger *slogger.Logger, opts config.Opts) { 82 | if val := os.Getenv("AZURETPL_EXPERIMENTAL_SUMMARY"); val != "true" && val != "1" { 83 | return 84 | } 85 | 86 | // skip empty summary 87 | if len(summary) == 0 { 88 | return 89 | } 90 | 91 | // github summary 92 | if summaryPath := os.Getenv("GITHUB_STEP_SUMMARY"); summaryPath != "" { 93 | content := buildSummary(opts) 94 | 95 | // If the file doesn't exist, create it, or append to the file 96 | f, err := os.OpenFile(summaryPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 97 | if err != nil { 98 | logger.Warn(`unable to post GITHUB step summary`, slog.Any("error", err)) 99 | return 100 | } 101 | defer f.Close() // nolint: errcheck 102 | 103 | if _, err := f.Write([]byte(content)); err != nil { 104 | logger.Warn(`unable to post GITHUB step summary`, slog.Any("error", err)) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | if [[ -z "$HELM_PLUGIN_DIR" ]]; then 6 | echo "ERROR: env var HELM_PLUGIN_DIR not set (script must be called by helm)" 7 | exit 1 8 | fi 9 | 10 | FORCE=0 11 | if [[ "$1" == "force" ]]; then 12 | FORCE=1 13 | fi 14 | 15 | HELM_AZURE_TPL_VERSION=$(sed -n -e 's/version:[ "]*\([^"]*\).*/\1/p' "${HELM_PLUGIN_DIR}/plugin.yaml") 16 | 17 | HOST_OS=$(uname -s | tr '[:upper:]' '[:lower:]') 18 | HOST_ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') 19 | 20 | FILE_SUFFIX="" 21 | 22 | case "${HOST_OS}" in 23 | cygwin*) 24 | HOST_OS="windows" 25 | FILE_SUFFIX=".exe" 26 | ;; 27 | mingw*) 28 | HOST_OS="windows" 29 | FILE_SUFFIX=".exe" 30 | ;; 31 | esac 32 | 33 | case "$HOST_ARCH" in 34 | "x86_64") 35 | ## translate to amd64 36 | HOST_ARCH="amd64" 37 | ;; 38 | "aarch64") 39 | ## translate to arm64 40 | HOST_ARCH="arm64" 41 | ;; 42 | esac 43 | 44 | PLUGIN_DOWNLOAD_FILE="helm-azure-tpl.${HOST_OS}.${HOST_ARCH}${FILE_SUFFIX}" 45 | PLUGIN_DOWNLOAD_URL="https://github.com/webdevops/helm-azure-tpl/releases/download/${HELM_AZURE_TPL_VERSION}/${PLUGIN_DOWNLOAD_FILE}" 46 | PLUGIN_TARGET_PATH="${HELM_PLUGIN_DIR}/helm-azure-tpl${FILE_SUFFIX}" 47 | 48 | echo "installing helm-azure-tpl executable" 49 | echo " platform: ${HOST_OS}/${HOST_ARCH}" 50 | echo " url: $PLUGIN_DOWNLOAD_URL" 51 | echo " target: $PLUGIN_TARGET_PATH" 52 | 53 | ## detect hostedtoolcache 54 | PLUGIN_CACHE_DIR="" 55 | PLUGIN_CACHE_FILE="" 56 | if [[ -n "$RUNNER_TOOL_CACHE" ]]; then 57 | PLUGIN_CACHE_DIR="${RUNNER_TOOL_CACHE}/helm-azure-tpl/${HELM_AZURE_TPL_VERSION}" 58 | PLUGIN_CACHE_FILE="${PLUGIN_CACHE_DIR}/${PLUGIN_DOWNLOAD_FILE}" 59 | echo " cache: $PLUGIN_CACHE_FILE" 60 | fi 61 | 62 | ## force (cleanup/upgrade) 63 | if [[ "$FORCE" -eq 1 && -f "$PLUGIN_TARGET_PATH" ]]; then 64 | echo "removing old executable (update/force mode)" 65 | rm -f -- "$PLUGIN_TARGET_PATH" 66 | fi 67 | 68 | ## get from hostedtoolcache 69 | if [[ -n "$PLUGIN_CACHE_FILE" && -e "$PLUGIN_CACHE_FILE" ]]; then 70 | echo "fetching from RUNNER_TOOL_CACHE ($RUNNER_TOOL_CACHE)" 71 | cp -- "$PLUGIN_CACHE_FILE" "$PLUGIN_TARGET_PATH" 72 | chmod +x "$PLUGIN_TARGET_PATH" 73 | fi 74 | 75 | ## download 76 | if [[ ! -e "$PLUGIN_TARGET_PATH" ]]; then 77 | echo "starting download (using curl)" 78 | curl --fail --location "$PLUGIN_DOWNLOAD_URL" -o "$PLUGIN_TARGET_PATH" 79 | if [ "$?" -ne 0 ]; then 80 | >&2 echo "[ERROR] failed to download plugin executable" 81 | exit 1 82 | fi 83 | 84 | ## store to hostedtoolcache 85 | if [[ -n "$PLUGIN_CACHE_FILE" ]]; then 86 | echo "storing to RUNNER_TOOL_CACHE ($RUNNER_TOOL_CACHE)" 87 | mkdir -p -- "${PLUGIN_CACHE_DIR}" 88 | cp -a -- "$PLUGIN_TARGET_PATH" "${PLUGIN_CACHE_FILE}" 89 | fi 90 | else 91 | echo "executable already exists, skipping download" 92 | fi 93 | 94 | if [[ ! -f "$PLUGIN_TARGET_PATH" ]]; then 95 | >&2 echo "[ERROR] installation of executable failed, please report issue" 96 | exit 1 97 | fi 98 | 99 | chmod +x "$PLUGIN_TARGET_PATH" 100 | 101 | echo "successfully installed executable" 102 | -------------------------------------------------------------------------------- /azuretpl/azure.subscription.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions" 8 | "github.com/webdevops/go-common/utils/to" 9 | ) 10 | 11 | // azSubscription fetches current or defined Azure subscription 12 | func (e *AzureTemplateExecutor) azSubscription(subscriptionID ...string) (interface{}, error) { 13 | var selectedSubscriptionId string 14 | if len(subscriptionID) > 1 { 15 | return nil, fmt.Errorf(`{{azSubscription}} only supports zero or one subscriptionIDs`) 16 | } 17 | 18 | if val, enabled := e.lintResult(); enabled { 19 | return val, nil 20 | } 21 | 22 | if len(subscriptionID) == 1 { 23 | // 24 | selectedSubscriptionId = subscriptionID[0] 25 | } else { 26 | // load az account info 27 | _, err := e.azAccountInfo() 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | // try to read subscription id 33 | if val, exists := e.azureCliAccountInfo["id"].(string); exists { 34 | selectedSubscriptionId = val 35 | } else { 36 | return nil, fmt.Errorf(`{{azSubscription}} is unable to find current subscription from "az account show" output`) 37 | } 38 | } 39 | 40 | e.logger.Info(`fetching Azure subscription`, slog.String("subscriptionID", selectedSubscriptionId)) 41 | 42 | cacheKey := generateCacheKey(`azSubscription`, selectedSubscriptionId) 43 | return e.cacheResult(cacheKey, func() (interface{}, error) { 44 | client, err := armsubscriptions.NewClient(e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | resource, err := client.Get(e.ctx, selectedSubscriptionId, nil) 50 | if err != nil { 51 | return nil, fmt.Errorf(`unable to fetch Azure subscription '%v': %w`, selectedSubscriptionId, err) 52 | } 53 | 54 | subscriptionData, err := transformToInterface(resource) 55 | if err != nil { 56 | return nil, fmt.Errorf(`unable to transform Azure subscription '%v': %w`, selectedSubscriptionId, err) 57 | } 58 | return subscriptionData, nil 59 | }) 60 | } 61 | 62 | // azSubscriptionList fetches list of visible Azure subscriptions 63 | func (e *AzureTemplateExecutor) azSubscriptionList() (interface{}, error) { 64 | e.logger.Info(`fetching Azure subscriptions`) 65 | 66 | if val, enabled := e.lintResult(); enabled { 67 | return val, nil 68 | } 69 | 70 | cacheKey := generateCacheKey(`azSubscriptionList`) 71 | return e.cacheResult(cacheKey, func() (interface{}, error) { 72 | client, err := armsubscriptions.NewClient(e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 73 | if err != nil { 74 | panic(err.Error()) 75 | } 76 | 77 | pager := client.NewListPager(nil) 78 | var ret []interface{} 79 | for pager.More() { 80 | result, err := pager.NextPage(e.ctx) 81 | if err != nil { 82 | panic(err) 83 | } 84 | 85 | for _, subscription := range result.Value { 86 | subscriptionData, err := transformToInterface(subscription) 87 | if err != nil { 88 | return nil, fmt.Errorf(`unable to transform Azure subscription '%v': %w`, to.String(subscription.SubscriptionID), err) 89 | } 90 | ret = append(ret, subscriptionData) 91 | } 92 | } 93 | 94 | return ret, nil 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /plugins/azure-tpl/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | if [[ -z "$HELM_PLUGIN_DIR" ]]; then 6 | echo "ERROR: env var HELM_PLUGIN_DIR not set (script must be called by helm)" 7 | exit 1 8 | fi 9 | 10 | FORCE=0 11 | if [[ "$1" == "force" ]]; then 12 | FORCE=1 13 | fi 14 | 15 | HELM_AZURE_TPL_VERSION=$(sed -n -e 's/version:[ "]*\([^"]*\).*/\1/p' "${HELM_PLUGIN_DIR}/plugin.yaml") 16 | 17 | HOST_OS=$(uname -s | tr '[:upper:]' '[:lower:]') 18 | HOST_ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') 19 | 20 | FILE_SUFFIX="" 21 | 22 | case "${HOST_OS}" in 23 | cygwin*) 24 | HOST_OS="windows" 25 | FILE_SUFFIX=".exe" 26 | ;; 27 | mingw*) 28 | HOST_OS="windows" 29 | FILE_SUFFIX=".exe" 30 | ;; 31 | esac 32 | 33 | case "$HOST_ARCH" in 34 | "x86_64") 35 | ## translate to amd64 36 | HOST_ARCH="amd64" 37 | ;; 38 | "aarch64") 39 | ## translate to arm64 40 | HOST_ARCH="arm64" 41 | ;; 42 | esac 43 | 44 | PLUGIN_DOWNLOAD_FILE="helm-azure-tpl.${HOST_OS}.${HOST_ARCH}${FILE_SUFFIX}" 45 | PLUGIN_DOWNLOAD_URL="https://github.com/webdevops/helm-azure-tpl/releases/download/${HELM_AZURE_TPL_VERSION}/${PLUGIN_DOWNLOAD_FILE}" 46 | PLUGIN_TARGET_PATH="${HELM_PLUGIN_DIR}/helm-azure-tpl${FILE_SUFFIX}" 47 | 48 | echo "installing helm-azure-tpl executable" 49 | echo " platform: ${HOST_OS}/${HOST_ARCH}" 50 | echo " url: $PLUGIN_DOWNLOAD_URL" 51 | echo " target: $PLUGIN_TARGET_PATH" 52 | 53 | ## detect hostedtoolcache 54 | PLUGIN_CACHE_DIR="" 55 | PLUGIN_CACHE_FILE="" 56 | if [[ -n "$RUNNER_TOOL_CACHE" ]]; then 57 | PLUGIN_CACHE_DIR="${RUNNER_TOOL_CACHE}/helm-azure-tpl/${HELM_AZURE_TPL_VERSION}" 58 | PLUGIN_CACHE_FILE="${PLUGIN_CACHE_DIR}/${PLUGIN_DOWNLOAD_FILE}" 59 | echo " cache: $PLUGIN_CACHE_FILE" 60 | fi 61 | 62 | ## force (cleanup/upgrade) 63 | if [[ "$FORCE" -eq 1 && -f "$PLUGIN_TARGET_PATH" ]]; then 64 | echo "removing old executable (update/force mode)" 65 | rm -f -- "$PLUGIN_TARGET_PATH" 66 | fi 67 | 68 | ## get from hostedtoolcache 69 | if [[ -n "$PLUGIN_CACHE_FILE" && -e "$PLUGIN_CACHE_FILE" ]]; then 70 | echo "fetching from RUNNER_TOOL_CACHE ($RUNNER_TOOL_CACHE)" 71 | cp -- "$PLUGIN_CACHE_FILE" "$PLUGIN_TARGET_PATH" 72 | chmod +x "$PLUGIN_TARGET_PATH" 73 | fi 74 | 75 | ## download 76 | if [[ ! -e "$PLUGIN_TARGET_PATH" ]]; then 77 | echo "starting download (using curl)" 78 | curl --fail --location "$PLUGIN_DOWNLOAD_URL" -o "$PLUGIN_TARGET_PATH" 79 | if [ "$?" -ne 0 ]; then 80 | >&2 echo "[ERROR] failed to download plugin executable" 81 | exit 1 82 | fi 83 | 84 | ## store to hostedtoolcache 85 | if [[ -n "$PLUGIN_CACHE_FILE" ]]; then 86 | echo "storing to RUNNER_TOOL_CACHE ($RUNNER_TOOL_CACHE)" 87 | mkdir -p -- "${PLUGIN_CACHE_DIR}" 88 | cp -a -- "$PLUGIN_TARGET_PATH" "${PLUGIN_CACHE_FILE}" 89 | fi 90 | else 91 | echo "executable already exists, skipping download" 92 | fi 93 | 94 | if [[ ! -f "$PLUGIN_TARGET_PATH" ]]; then 95 | >&2 echo "[ERROR] installation of executable failed, please report issue" 96 | exit 1 97 | fi 98 | 99 | chmod +x "$PLUGIN_TARGET_PATH" 100 | 101 | echo "successfully installed executable" 102 | -------------------------------------------------------------------------------- /plugins/azure-tpl-cli/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | if [[ -z "$HELM_PLUGIN_DIR" ]]; then 6 | echo "ERROR: env var HELM_PLUGIN_DIR not set (script must be called by helm)" 7 | exit 1 8 | fi 9 | 10 | FORCE=0 11 | if [[ "$1" == "force" ]]; then 12 | FORCE=1 13 | fi 14 | 15 | HELM_AZURE_TPL_VERSION=$(sed -n -e 's/version:[ "]*\([^"]*\).*/\1/p' "${HELM_PLUGIN_DIR}/plugin.yaml") 16 | 17 | HOST_OS=$(uname -s | tr '[:upper:]' '[:lower:]') 18 | HOST_ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') 19 | 20 | FILE_SUFFIX="" 21 | 22 | case "${HOST_OS}" in 23 | cygwin*) 24 | HOST_OS="windows" 25 | FILE_SUFFIX=".exe" 26 | ;; 27 | mingw*) 28 | HOST_OS="windows" 29 | FILE_SUFFIX=".exe" 30 | ;; 31 | esac 32 | 33 | case "$HOST_ARCH" in 34 | "x86_64") 35 | ## translate to amd64 36 | HOST_ARCH="amd64" 37 | ;; 38 | "aarch64") 39 | ## translate to arm64 40 | HOST_ARCH="arm64" 41 | ;; 42 | esac 43 | 44 | PLUGIN_DOWNLOAD_FILE="helm-azure-tpl.${HOST_OS}.${HOST_ARCH}${FILE_SUFFIX}" 45 | PLUGIN_DOWNLOAD_URL="https://github.com/webdevops/helm-azure-tpl/releases/download/${HELM_AZURE_TPL_VERSION}/${PLUGIN_DOWNLOAD_FILE}" 46 | PLUGIN_TARGET_PATH="${HELM_PLUGIN_DIR}/helm-azure-tpl${FILE_SUFFIX}" 47 | 48 | echo "installing helm-azure-tpl executable" 49 | echo " platform: ${HOST_OS}/${HOST_ARCH}" 50 | echo " url: $PLUGIN_DOWNLOAD_URL" 51 | echo " target: $PLUGIN_TARGET_PATH" 52 | 53 | ## detect hostedtoolcache 54 | PLUGIN_CACHE_DIR="" 55 | PLUGIN_CACHE_FILE="" 56 | if [[ -n "$RUNNER_TOOL_CACHE" ]]; then 57 | PLUGIN_CACHE_DIR="${RUNNER_TOOL_CACHE}/helm-azure-tpl/${HELM_AZURE_TPL_VERSION}" 58 | PLUGIN_CACHE_FILE="${PLUGIN_CACHE_DIR}/${PLUGIN_DOWNLOAD_FILE}" 59 | echo " cache: $PLUGIN_CACHE_FILE" 60 | fi 61 | 62 | ## force (cleanup/upgrade) 63 | if [[ "$FORCE" -eq 1 && -f "$PLUGIN_TARGET_PATH" ]]; then 64 | echo "removing old executable (update/force mode)" 65 | rm -f -- "$PLUGIN_TARGET_PATH" 66 | fi 67 | 68 | ## get from hostedtoolcache 69 | if [[ -n "$PLUGIN_CACHE_FILE" && -e "$PLUGIN_CACHE_FILE" ]]; then 70 | echo "fetching from RUNNER_TOOL_CACHE ($RUNNER_TOOL_CACHE)" 71 | cp -- "$PLUGIN_CACHE_FILE" "$PLUGIN_TARGET_PATH" 72 | chmod +x "$PLUGIN_TARGET_PATH" 73 | fi 74 | 75 | ## download 76 | if [[ ! -e "$PLUGIN_TARGET_PATH" ]]; then 77 | echo "starting download (using curl)" 78 | curl --fail --location "$PLUGIN_DOWNLOAD_URL" -o "$PLUGIN_TARGET_PATH" 79 | if [ "$?" -ne 0 ]; then 80 | >&2 echo "[ERROR] failed to download plugin executable" 81 | exit 1 82 | fi 83 | 84 | ## store to hostedtoolcache 85 | if [[ -n "$PLUGIN_CACHE_FILE" ]]; then 86 | echo "storing to RUNNER_TOOL_CACHE ($RUNNER_TOOL_CACHE)" 87 | mkdir -p -- "${PLUGIN_CACHE_DIR}" 88 | cp -a -- "$PLUGIN_TARGET_PATH" "${PLUGIN_CACHE_FILE}" 89 | fi 90 | else 91 | echo "executable already exists, skipping download" 92 | fi 93 | 94 | if [[ ! -f "$PLUGIN_TARGET_PATH" ]]; then 95 | >&2 echo "[ERROR] installation of executable failed, please report issue" 96 | exit 1 97 | fi 98 | 99 | chmod +x "$PLUGIN_TARGET_PATH" 100 | 101 | echo "successfully installed executable" 102 | -------------------------------------------------------------------------------- /plugins/azure-tpl-getter/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | if [[ -z "$HELM_PLUGIN_DIR" ]]; then 6 | echo "ERROR: env var HELM_PLUGIN_DIR not set (script must be called by helm)" 7 | exit 1 8 | fi 9 | 10 | FORCE=0 11 | if [[ "$1" == "force" ]]; then 12 | FORCE=1 13 | fi 14 | 15 | HELM_AZURE_TPL_VERSION=$(sed -n -e 's/version:[ "]*\([^"]*\).*/\1/p' "${HELM_PLUGIN_DIR}/plugin.yaml") 16 | 17 | HOST_OS=$(uname -s | tr '[:upper:]' '[:lower:]') 18 | HOST_ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') 19 | 20 | FILE_SUFFIX="" 21 | 22 | case "${HOST_OS}" in 23 | cygwin*) 24 | HOST_OS="windows" 25 | FILE_SUFFIX=".exe" 26 | ;; 27 | mingw*) 28 | HOST_OS="windows" 29 | FILE_SUFFIX=".exe" 30 | ;; 31 | esac 32 | 33 | case "$HOST_ARCH" in 34 | "x86_64") 35 | ## translate to amd64 36 | HOST_ARCH="amd64" 37 | ;; 38 | "aarch64") 39 | ## translate to arm64 40 | HOST_ARCH="arm64" 41 | ;; 42 | esac 43 | 44 | PLUGIN_DOWNLOAD_FILE="helm-azure-tpl.${HOST_OS}.${HOST_ARCH}${FILE_SUFFIX}" 45 | PLUGIN_DOWNLOAD_URL="https://github.com/webdevops/helm-azure-tpl/releases/download/${HELM_AZURE_TPL_VERSION}/${PLUGIN_DOWNLOAD_FILE}" 46 | PLUGIN_TARGET_PATH="${HELM_PLUGIN_DIR}/helm-azure-tpl${FILE_SUFFIX}" 47 | 48 | echo "installing helm-azure-tpl executable" 49 | echo " platform: ${HOST_OS}/${HOST_ARCH}" 50 | echo " url: $PLUGIN_DOWNLOAD_URL" 51 | echo " target: $PLUGIN_TARGET_PATH" 52 | 53 | ## detect hostedtoolcache 54 | PLUGIN_CACHE_DIR="" 55 | PLUGIN_CACHE_FILE="" 56 | if [[ -n "$RUNNER_TOOL_CACHE" ]]; then 57 | PLUGIN_CACHE_DIR="${RUNNER_TOOL_CACHE}/helm-azure-tpl/${HELM_AZURE_TPL_VERSION}" 58 | PLUGIN_CACHE_FILE="${PLUGIN_CACHE_DIR}/${PLUGIN_DOWNLOAD_FILE}" 59 | echo " cache: $PLUGIN_CACHE_FILE" 60 | fi 61 | 62 | ## force (cleanup/upgrade) 63 | if [[ "$FORCE" -eq 1 && -f "$PLUGIN_TARGET_PATH" ]]; then 64 | echo "removing old executable (update/force mode)" 65 | rm -f -- "$PLUGIN_TARGET_PATH" 66 | fi 67 | 68 | ## get from hostedtoolcache 69 | if [[ -n "$PLUGIN_CACHE_FILE" && -e "$PLUGIN_CACHE_FILE" ]]; then 70 | echo "fetching from RUNNER_TOOL_CACHE ($RUNNER_TOOL_CACHE)" 71 | cp -- "$PLUGIN_CACHE_FILE" "$PLUGIN_TARGET_PATH" 72 | chmod +x "$PLUGIN_TARGET_PATH" 73 | fi 74 | 75 | ## download 76 | if [[ ! -e "$PLUGIN_TARGET_PATH" ]]; then 77 | echo "starting download (using curl)" 78 | curl --fail --location "$PLUGIN_DOWNLOAD_URL" -o "$PLUGIN_TARGET_PATH" 79 | if [ "$?" -ne 0 ]; then 80 | >&2 echo "[ERROR] failed to download plugin executable" 81 | exit 1 82 | fi 83 | 84 | ## store to hostedtoolcache 85 | if [[ -n "$PLUGIN_CACHE_FILE" ]]; then 86 | echo "storing to RUNNER_TOOL_CACHE ($RUNNER_TOOL_CACHE)" 87 | mkdir -p -- "${PLUGIN_CACHE_DIR}" 88 | cp -a -- "$PLUGIN_TARGET_PATH" "${PLUGIN_CACHE_FILE}" 89 | fi 90 | else 91 | echo "executable already exists, skipping download" 92 | fi 93 | 94 | if [[ ! -f "$PLUGIN_TARGET_PATH" ]]; then 95 | >&2 echo "[ERROR] installation of executable failed, please report issue" 96 | exit 1 97 | fi 98 | 99 | chmod +x "$PLUGIN_TARGET_PATH" 100 | 101 | echo "successfully installed executable" 102 | -------------------------------------------------------------------------------- /azuretpl/msgraph.group.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | msgraphcore "github.com/microsoftgraph/msgraph-sdk-go-core" 8 | "github.com/microsoftgraph/msgraph-sdk-go/groups" 9 | "github.com/microsoftgraph/msgraph-sdk-go/models" 10 | "github.com/webdevops/go-common/utils/to" 11 | ) 12 | 13 | // mgGroupByDisplayName fetches one group from MsGraph API using displayName 14 | func (e *AzureTemplateExecutor) mgGroupByDisplayName(displayName string) (interface{}, error) { 15 | e.logger.Info(`fetching MsGraph group by displayName`, slog.String("displayName", displayName)) 16 | 17 | if val, enabled := e.lintResult(); enabled { 18 | return val, nil 19 | } 20 | cacheKey := generateCacheKey(`mgGroupByDisplayName`, displayName) 21 | return e.cacheResult(cacheKey, func() (interface{}, error) { 22 | requestOpts := &groups.GroupsRequestBuilderGetRequestConfiguration{ 23 | QueryParameters: &groups.GroupsRequestBuilderGetQueryParameters{ 24 | Filter: to.StringPtr(fmt.Sprintf(`displayName eq '%v'`, 25 | escapeMsGraphFilter(displayName))), 26 | }, 27 | } 28 | result, err := e.msGraphClient().ServiceClient().Groups().Get(e.ctx, requestOpts) 29 | if err != nil { 30 | return nil, fmt.Errorf(`failed to query MsGraph group: %w`, err) 31 | } 32 | 33 | list, err := e.mgGroupCreateListFromResult(result) 34 | if err != nil { 35 | return nil, fmt.Errorf(`failed to query MsGraph group: %w`, err) 36 | } 37 | 38 | switch len(list) { 39 | case 0: 40 | return nil, nil 41 | case 1: 42 | return list[0], nil 43 | default: 44 | return nil, fmt.Errorf(`found more then one group '%v'`, displayName) 45 | } 46 | }) 47 | } 48 | 49 | // mgGroupList fetches list of groups from MsGraph API using $filter query 50 | func (e *AzureTemplateExecutor) mgGroupList(filter string) (interface{}, error) { 51 | e.logger.Info(`fetching MsGraph group list with $filter`, slog.String("filter", filter)) 52 | 53 | if val, enabled := e.lintResult(); enabled { 54 | return val, nil 55 | } 56 | cacheKey := generateCacheKey(`mgGroupList`, filter) 57 | return e.cacheResult(cacheKey, func() (interface{}, error) { 58 | result, err := e.msGraphClient().ServiceClient().Groups().Get(e.ctx, nil) 59 | if err != nil { 60 | return nil, fmt.Errorf(`failed to query MsGraph group: %w`, err) 61 | } 62 | 63 | list, err := e.mgGroupCreateListFromResult(result) 64 | if err != nil { 65 | return nil, fmt.Errorf(`failed to query MsGraph groups: %w`, err) 66 | } 67 | 68 | return list, nil 69 | }) 70 | 71 | } 72 | 73 | func (e *AzureTemplateExecutor) mgGroupCreateListFromResult(result models.GroupCollectionResponseable) (list []interface{}, err error) { 74 | pageIterator, pageIteratorErr := msgraphcore.NewPageIterator[models.Groupable](result, e.msGraphClient().RequestAdapter(), models.CreateGroupCollectionResponseFromDiscriminatorValue) 75 | if pageIteratorErr != nil { 76 | return list, pageIteratorErr 77 | } 78 | 79 | iterateErr := pageIterator.Iterate(e.ctx, func(group models.Groupable) bool { 80 | obj, serializeErr := e.mgSerializeObject(group) 81 | if serializeErr != nil { 82 | err = serializeErr 83 | return false 84 | } 85 | 86 | list = append(list, obj) 87 | return true 88 | }) 89 | if iterateErr != nil { 90 | return list, iterateErr 91 | } 92 | 93 | return 94 | } 95 | -------------------------------------------------------------------------------- /azuretpl/azure.resources.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" 8 | "github.com/webdevops/go-common/azuresdk/armclient" 9 | "github.com/webdevops/go-common/utils/to" 10 | ) 11 | 12 | // azResource fetches resource json from Azure REST API using the specified apiVersion 13 | func (e *AzureTemplateExecutor) azResource(resourceID string, apiVersion string) (interface{}, error) { 14 | e.logger.Info(`fetching Azure Resource`, slog.String("resourceID", resourceID), slog.String("apiVersion", apiVersion)) 15 | 16 | cacheKey := generateCacheKey(`azResource`, resourceID, apiVersion) 17 | return e.cacheResult(cacheKey, func() (interface{}, error) { 18 | return e.fetchAzureResource(resourceID, apiVersion) 19 | }) 20 | } 21 | 22 | // azResourceList fetches list of resources by scope (either subscription or resourcegroup) from Azure REST API 23 | func (e *AzureTemplateExecutor) azResourceList(scope string, opts ...string) (interface{}, error) { 24 | filter := "" 25 | if len(opts) >= 1 { 26 | filter = opts[0] 27 | } 28 | 29 | e.logger.Info(`fetching Azure Resource list`, slog.String("scope", scope), slog.String("filter", filter)) 30 | 31 | if val, enabled := e.lintResult(); enabled { 32 | return val, nil 33 | } 34 | 35 | cacheKey := generateCacheKey(`azResourceList`, scope, filter) 36 | return e.cacheResult(cacheKey, func() (interface{}, error) { 37 | scopeInfo, err := armclient.ParseResourceId(scope) 38 | if err != nil { 39 | return nil, fmt.Errorf(`unable to parse Azure resourceID '%v': %w`, scope, err) 40 | } 41 | 42 | client, err := armresources.NewClient(scopeInfo.Subscription, e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | ret := []interface{}{} 48 | if scopeInfo.ResourceGroup != "" { 49 | // list by ResourceGroup 50 | options := armresources.ClientListByResourceGroupOptions{} 51 | if filter != "" { 52 | options.Filter = to.StringPtr(filter) 53 | } 54 | 55 | pager := client.NewListByResourceGroupPager(scopeInfo.ResourceGroup, &options) 56 | for pager.More() { 57 | result, err := pager.NextPage(e.ctx) 58 | if err != nil { 59 | panic(err) 60 | } 61 | 62 | for _, resource := range result.Value { 63 | resourceData, err := transformToInterface(resource) 64 | if err != nil { 65 | return nil, fmt.Errorf(`unable to transform Azure resource '%v': %w`, to.String(resource.ID), err) 66 | } 67 | ret = append(ret, resourceData) 68 | } 69 | } 70 | } else { 71 | // list by Subscription 72 | options := armresources.ClientListOptions{} 73 | if filter != "" { 74 | options.Filter = to.StringPtr(filter) 75 | } 76 | 77 | pager := client.NewListPager(&options) 78 | for pager.More() { 79 | result, err := pager.NextPage(e.ctx) 80 | if err != nil { 81 | panic(err) 82 | } 83 | 84 | for _, resource := range result.Value { 85 | resourceData, err := transformToInterface(resource) 86 | if err != nil { 87 | return nil, fmt.Errorf(`unable to transform Azure resource '%v': %w`, to.String(resource.ID), err) 88 | } 89 | ret = append(ret, resourceData) 90 | } 91 | } 92 | } 93 | 94 | return ret, nil 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /azuretpl/msgraph.user.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | msgraphcore "github.com/microsoftgraph/msgraph-sdk-go-core" 8 | "github.com/microsoftgraph/msgraph-sdk-go/models" 9 | "github.com/microsoftgraph/msgraph-sdk-go/users" 10 | "github.com/webdevops/go-common/utils/to" 11 | ) 12 | 13 | // mgUserByUserPrincipalName fetches one user from MsGraph API using userPrincipalName 14 | func (e *AzureTemplateExecutor) mgUserByUserPrincipalName(userPrincipalName string) (interface{}, error) { 15 | e.logger.Info(`fetching MsGraph user by userPrincipalName`, slog.String("upn", userPrincipalName)) 16 | 17 | if val, enabled := e.lintResult(); enabled { 18 | return val, nil 19 | } 20 | 21 | cacheKey := generateCacheKey(`mgUserByUserPrincipalName`, userPrincipalName) 22 | return e.cacheResult(cacheKey, func() (interface{}, error) { 23 | requestOpts := &users.UsersRequestBuilderGetRequestConfiguration{ 24 | QueryParameters: &users.UsersRequestBuilderGetQueryParameters{ 25 | Filter: to.StringPtr(fmt.Sprintf( 26 | `userPrincipalName eq '%v'`, 27 | escapeMsGraphFilter(userPrincipalName), 28 | )), 29 | }, 30 | } 31 | result, err := e.msGraphClient().ServiceClient().Users().Get(e.ctx, requestOpts) 32 | if err != nil { 33 | return nil, fmt.Errorf(`failed to query MsGraph user: %w`, err) 34 | } 35 | 36 | list, err := e.mgUserCreateListFromResult(result) 37 | if err != nil { 38 | return nil, fmt.Errorf(`failed to query MsGraph user: %w`, err) 39 | } 40 | 41 | switch len(list) { 42 | case 0: 43 | return nil, nil 44 | case 1: 45 | return list[0], nil 46 | default: 47 | return nil, fmt.Errorf(`found more then one user '%v'`, userPrincipalName) 48 | } 49 | }) 50 | } 51 | 52 | // mgUserList fetches list of users from MsGraph API using $filter query 53 | func (e *AzureTemplateExecutor) mgUserList(filter string) (interface{}, error) { 54 | e.logger.Info(`fetching MsGraph user list with $filter`, slog.String("filter", filter)) 55 | 56 | if val, enabled := e.lintResult(); enabled { 57 | return val, nil 58 | } 59 | 60 | cacheKey := generateCacheKey(`mgUserList`, filter) 61 | return e.cacheResult(cacheKey, func() (interface{}, error) { 62 | result, err := e.msGraphClient().ServiceClient().Users().Get(e.ctx, nil) 63 | if err != nil { 64 | return nil, fmt.Errorf(`failed to query MsGraph users: %w`, err) 65 | } 66 | 67 | list, err := e.mgUserCreateListFromResult(result) 68 | if err != nil { 69 | return nil, fmt.Errorf(`failed to query MsGraph users: %w`, err) 70 | } 71 | 72 | return list, nil 73 | }) 74 | } 75 | 76 | func (e *AzureTemplateExecutor) mgUserCreateListFromResult(result models.UserCollectionResponseable) (list []interface{}, err error) { 77 | pageIterator, pageIteratorErr := msgraphcore.NewPageIterator[models.Userable](result, e.msGraphClient().RequestAdapter(), models.CreateUserCollectionResponseFromDiscriminatorValue) 78 | if pageIteratorErr != nil { 79 | return list, pageIteratorErr 80 | } 81 | 82 | iterateErr := pageIterator.Iterate(e.ctx, func(user models.Userable) bool { 83 | obj, serializeErr := e.mgSerializeObject(user) 84 | if serializeErr != nil { 85 | err = serializeErr 86 | return false 87 | } 88 | 89 | list = append(list, obj) 90 | return true 91 | }) 92 | if iterateErr != nil { 93 | return list, iterateErr 94 | } 95 | 96 | return 97 | } 98 | -------------------------------------------------------------------------------- /config/opts.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/webdevops/helm-azure-tpl/azuretpl/models" 7 | ) 8 | 9 | type ( 10 | Opts struct { 11 | // logger 12 | Logger struct { 13 | Level string `long:"log.level" env:"AZURETPL_LOG_LEVEL" description:"Log level" choice:"trace" choice:"debug" choice:"info" choice:"warning" choice:"error" default:"info"` // nolint:staticcheck // multiple choices are ok 14 | Format string `long:"log.format" env:"AZURETPL_LOG_FORMAT" description:"Log format" choice:"logfmt" choice:"json" default:"logfmt"` // nolint:staticcheck // multiple choices are ok 15 | Source string `long:"log.source" env:"AZURETPL_LOG_SOURCE" description:"Show source for every log message (useful for debugging and bug reports)" choice:"" choice:"short" choice:"file" choice:"full"` // nolint:staticcheck // multiple choices are ok 16 | Color string `long:"log.color" env:"AZURETPL_LOG_COLOR" description:"Enable color for logs" choice:"" choice:"auto" choice:"yes" choice:"no"` // nolint:staticcheck // multiple choices are ok 17 | Time bool `long:"log.time" env:"AZURETPL_LOG_TIME" description:"Show log time"` 18 | } 19 | 20 | // Api option 21 | Azure struct { 22 | Tenant *string `env:"AZURE_TENANT_ID" description:"Azure tenant id"` 23 | Environment *string `env:"AZURE_ENVIRONMENT" description:"Azure environment name"` 24 | } 25 | 26 | DryRun bool `long:"dry-run" env:"AZURETPL_DRY_RUN" description:"dry run, do not write any files"` 27 | Debug bool `long:"debug" env:"HELMHELM_DEBUG_DEBUG" description:"debug run, print generated content to stdout (WARNING: can expose secrets!)"` 28 | Stdout bool `long:"stdout" env:"AZURETPL_STDOUT" description:"Print parsed content to stdout instead of file (logs will be written to stderr)"` 29 | 30 | Template struct { 31 | BasePath *string `long:"template.basepath" env:"AZURETPL_TEMPLATE_BASEPATH" description:"sets custom base path (if empty, base path is set by base directory for each file. will be appended to all root paths inside templates)"` 32 | } 33 | 34 | Target struct { 35 | Prefix string `long:"target.prefix" env:"AZURETPL_TARGET_PREFIX" description:"adds this value as prefix to filename on save (not used if targetfile is specified in argument)"` 36 | Suffix string `long:"target.suffix" env:"AZURETPL_TARGET_SUFFIX" description:"adds this value as suffix to filename on save (not used if targetfile is specified in argument)"` 37 | FileExt *string `long:"target.fileext" env:"AZURETPL_TARGET_FILEEXT" description:"replaces file extension (or adds if empty) with this value (eg. '.yaml')"` 38 | } 39 | 40 | AzureTpl models.Opts 41 | 42 | Args struct { 43 | Command string `positional-arg-name:"command" description:"specifies what to do (help, version, lint, apply)" choice:"help" choice:"version" choice:"lint" choice:"apply" required:"yes"` // nolint:staticcheck 44 | Files []string `positional-arg-name:"files" description:"list of files to process (will overwrite files, different target file can be specified as sourcefile:targetfile)"` 45 | } `positional-args:"yes" ` 46 | } 47 | ) 48 | 49 | func (o *Opts) GetJson() []byte { 50 | jsonBytes, err := json.Marshal(o) 51 | if err != nil { 52 | panic(err) 53 | } 54 | return jsonBytes 55 | } 56 | -------------------------------------------------------------------------------- /azuretpl/cicd.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "sync/atomic" 9 | ) 10 | 11 | var ( 12 | cicdMaskVarNumber atomic.Uint64 13 | ) 14 | 15 | func (e *AzureTemplateExecutor) handleCicdMaskSecret(val string) { 16 | workflowLogMsgList := []string{} 17 | 18 | // only show first line of error (could be a multi line error message) 19 | val = strings.SplitN(val, "\n", 2)[0] 20 | 21 | switch { 22 | case os.Getenv("SYSTEM_TEAMFOUNDATIONSERVERURI") != "": 23 | // Azure DevOps 24 | workflowLogMsgList = append( 25 | workflowLogMsgList, 26 | fmt.Sprintf(`##vso[task.setvariable variable=HELM_AZURETPL_SECRET_MASK_%d;isSecret=true]%v`, cicdMaskVarNumber.Add(1), val), 27 | fmt.Sprintf(`##vso[task.setvariable variable=HELM_AZURETPL_SECRET_MASK_%d;isSecret=true]%v`, cicdMaskVarNumber.Add(1), base64.StdEncoding.EncodeToString([]byte(val))), 28 | ) 29 | case os.Getenv("GITLAB_CI") != "": 30 | // GitLab 31 | // no secret masking available 32 | case os.Getenv("JENKINS_URL") != "": 33 | // Jenkins 34 | // no secret masking available 35 | case os.Getenv("GITHUB_ACTION") != "": 36 | // GitHub 37 | workflowLogMsgList = append( 38 | workflowLogMsgList, 39 | fmt.Sprintf(`::add-mask::%v`, val), 40 | fmt.Sprintf(`::add-mask::%v`, base64.StdEncoding.EncodeToString([]byte(val))), 41 | ) 42 | } 43 | 44 | for _, workflowLogMsg := range workflowLogMsgList { 45 | fmt.Fprintln(os.Stderr, workflowLogMsg) 46 | } 47 | } 48 | 49 | func (e *AzureTemplateExecutor) handleCicdWarning(err error) string { 50 | workflowLogMsg := "" 51 | 52 | // only show first line of error (could be a multi line error message) 53 | workflowLogError := strings.SplitN(err.Error(), "\n", 2)[0] 54 | 55 | switch { 56 | case os.Getenv("SYSTEM_TEAMFOUNDATIONSERVERURI") != "": 57 | // Azure DevOps 58 | workflowLogMsg = fmt.Sprintf(`##vso[task.logissue type=warning;sourcepath=%v]%v`, e.currentPath, workflowLogError) 59 | case os.Getenv("GITLAB_CI") != "": 60 | // GitLab 61 | // no error logging available 62 | case os.Getenv("JENKINS_URL") != "": 63 | // Jenkins 64 | // no error logging available 65 | case os.Getenv("GITHUB_ACTION") != "": 66 | // GitHub 67 | workflowLogMsg = fmt.Sprintf(`::warning file=%v,title=helm-azure-tpl::%v`, e.currentPath, workflowLogError) 68 | } 69 | 70 | if workflowLogMsg != "" { 71 | fmt.Fprintln(os.Stderr, workflowLogMsg) 72 | } 73 | 74 | return err.Error() 75 | } 76 | 77 | func (e *AzureTemplateExecutor) handleCicdError(err error) error { 78 | workflowLogMsg := "" 79 | 80 | // only show first line of error (could be a multi line error message) 81 | workflowLogError := strings.SplitN(err.Error(), "\n", 2)[0] 82 | 83 | switch { 84 | case os.Getenv("SYSTEM_TEAMFOUNDATIONSERVERURI") != "": 85 | // Azure DevOps 86 | workflowLogMsg = fmt.Sprintf(`##vso[task.logissue type=error;sourcepath=%v]%v`, e.currentPath, workflowLogError) 87 | case os.Getenv("GITLAB_CI") != "": 88 | // GitLab 89 | // no error logging available 90 | case os.Getenv("JENKINS_URL") != "": 91 | // Jenkins 92 | // no error logging available 93 | case os.Getenv("GITHUB_ACTION") != "": 94 | // GitHub 95 | workflowLogMsg = fmt.Sprintf(`::error file=%v,title=helm-azure-tpl::%v`, e.currentPath, workflowLogError) 96 | } 97 | 98 | if workflowLogMsg != "" { 99 | fmt.Fprintln(os.Stderr, workflowLogMsg) 100 | } 101 | 102 | return err 103 | } 104 | -------------------------------------------------------------------------------- /azuretpl/msgraph.application.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | msgraphcore "github.com/microsoftgraph/msgraph-sdk-go-core" 8 | "github.com/microsoftgraph/msgraph-sdk-go/applications" 9 | "github.com/microsoftgraph/msgraph-sdk-go/models" 10 | "github.com/webdevops/go-common/utils/to" 11 | ) 12 | 13 | // mgApplicationByDisplayName fetches one application from MsGraph API using displayName 14 | func (e *AzureTemplateExecutor) mgApplicationByDisplayName(displayName string) (interface{}, error) { 15 | e.logger.Info(`fetching MsGraph application by displayName`, slog.String("displayName", displayName)) 16 | 17 | if val, enabled := e.lintResult(); enabled { 18 | return val, nil 19 | } 20 | 21 | cacheKey := generateCacheKey(`mgApplicationByDisplayName`, displayName) 22 | return e.cacheResult(cacheKey, func() (interface{}, error) { 23 | requestOpts := &applications.ApplicationsRequestBuilderGetRequestConfiguration{ 24 | QueryParameters: &applications.ApplicationsRequestBuilderGetQueryParameters{ 25 | Filter: to.StringPtr(fmt.Sprintf(`displayName eq '%v'`, 26 | escapeMsGraphFilter(displayName))), 27 | }, 28 | } 29 | result, err := e.msGraphClient().ServiceClient().Applications().Get(e.ctx, requestOpts) 30 | if err != nil { 31 | return nil, fmt.Errorf(`failed to query MsGraph application: %w`, err) 32 | } 33 | 34 | list, err := e.mgApplicationCreateListFromResult(result) 35 | if err != nil { 36 | return nil, fmt.Errorf(`failed to query MsGraph application: %w`, err) 37 | } 38 | 39 | switch len(list) { 40 | case 0: 41 | return nil, nil 42 | case 1: 43 | return list[0], nil 44 | default: 45 | return nil, fmt.Errorf(`found more then one application '%v'`, displayName) 46 | } 47 | }) 48 | } 49 | 50 | // mgApplicationList fetches list of applications from MsGraph API using $filter query 51 | func (e *AzureTemplateExecutor) mgApplicationList(filter string) (interface{}, error) { 52 | e.logger.Info(`fetching MsGraph application list with $filter`, slog.String("filter", filter)) 53 | 54 | if val, enabled := e.lintResult(); enabled { 55 | return val, nil 56 | } 57 | 58 | cacheKey := generateCacheKey(`mgApplicationList`, filter) 59 | return e.cacheResult(cacheKey, func() (interface{}, error) { 60 | result, err := e.msGraphClient().ServiceClient().Applications().Get(e.ctx, nil) 61 | if err != nil { 62 | return nil, fmt.Errorf(`failed to query MsGraph applications: %w`, err) 63 | } 64 | 65 | list, err := e.mgApplicationCreateListFromResult(result) 66 | if err != nil { 67 | return nil, fmt.Errorf(`failed to query MsGraph applications: %w`, err) 68 | } 69 | 70 | return list, nil 71 | }) 72 | } 73 | 74 | func (e *AzureTemplateExecutor) mgApplicationCreateListFromResult(result models.ApplicationCollectionResponseable) (list []interface{}, err error) { 75 | pageIterator, pageIteratorErr := msgraphcore.NewPageIterator[models.Applicationable](result, e.msGraphClient().RequestAdapter(), models.CreateApplicationCollectionResponseFromDiscriminatorValue) 76 | if pageIteratorErr != nil { 77 | return list, pageIteratorErr 78 | } 79 | 80 | iterateErr := pageIterator.Iterate(e.ctx, func(application models.Applicationable) bool { 81 | obj, serializeErr := e.mgSerializeObject(application) 82 | if serializeErr != nil { 83 | err = serializeErr 84 | return false 85 | } 86 | 87 | list = append(list, obj) 88 | return true 89 | }) 90 | if iterateErr != nil { 91 | return list, iterateErr 92 | } 93 | 94 | return 95 | } 96 | -------------------------------------------------------------------------------- /.github/workflows/release-assets.yaml: -------------------------------------------------------------------------------- 1 | name: "release/assets" 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: false 10 | 11 | env: 12 | RELEASE_TAG: ${{ github.ref_name }} 13 | 14 | permissions: 15 | contents: write 16 | packages: write 17 | 18 | jobs: 19 | build: 20 | name: "${{ matrix.task }}" 21 | runs-on: ubuntu-latest 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | include: 26 | - task: release-assets-linux 27 | - task: release-assets-darwin 28 | - task: release-assets-windows 29 | steps: 30 | - uses: actions/checkout@v6 31 | 32 | - name: Setup runner 33 | uses: ./.github/actions/setup-runner 34 | 35 | - name: Setup go 36 | uses: ./.github/actions/setup-go 37 | 38 | - name: Build 39 | run: | 40 | make "${{ matrix.task }}" 41 | 42 | - name: Upload assets to release 43 | uses: svenstaro/upload-release-action@v2 44 | with: 45 | repo_token: ${{ secrets.GITHUB_TOKEN }} 46 | file: ./release-assets/* 47 | tag: ${{ github.ref }} 48 | overwrite: true 49 | file_glob: true 50 | promote: false 51 | 52 | plugins: 53 | name: "assets/plugins" 54 | runs-on: ubuntu-latest 55 | needs: [build] 56 | steps: 57 | - uses: actions/checkout@v6 58 | 59 | - name: Setup runner 60 | uses: ./.github/actions/setup-runner 61 | 62 | - name: Setup Helm 63 | uses: azure/setup-helm@v4 64 | 65 | - name: Set up ORAS 66 | uses: oras-project/setup-oras@v1 67 | 68 | - name: Build plugins 69 | run: | 70 | mkdir -p ./release-assets/ 71 | make plugins 72 | 73 | - name: Log in to Container Registry 74 | uses: docker/login-action@v3 75 | with: 76 | registry: ghcr.io 77 | username: ${{ github.actor }} 78 | password: ${{ secrets.GHCR_TOKEN }} 79 | 80 | - name: Publish azure-tpl-cli 81 | run: | 82 | oras push \ 83 | ghcr.io/${{ github.repository }}/azure-tpl-cli:${RELEASE_TAG},latest \ 84 | --artifact-type application/vnd.helm.plugin.v1+json \ 85 | azure-tpl-cli-*.tgz 86 | working-directory: ./release-assets/ 87 | 88 | - name: Publish azure-tpl-getter 89 | run: | 90 | oras push \ 91 | ghcr.io/${{ github.repository }}/azure-tpl-getter:${RELEASE_TAG},latest \ 92 | --artifact-type application/vnd.helm.plugin.v1+json \ 93 | azure-tpl-getter-*.tgz 94 | working-directory: ./release-assets/ 95 | 96 | - name: Publish azure-tpl (legacy) 97 | run: | 98 | oras push \ 99 | ghcr.io/${{ github.repository }}/azure-tpl:${RELEASE_TAG},latest \ 100 | --artifact-type application/vnd.helm.plugin.v1+json \ 101 | azure-tpl-*.tgz 102 | working-directory: ./release-assets/ 103 | 104 | - name: Upload assets to release 105 | uses: svenstaro/upload-release-action@v2 106 | with: 107 | repo_token: ${{ secrets.GITHUB_TOKEN }} 108 | file: ./release-assets/* 109 | tag: ${{ github.ref }} 110 | overwrite: true 111 | file_glob: true 112 | promote: true 113 | make_latest: true 114 | -------------------------------------------------------------------------------- /azuretpl/msgraph.serviceprincipal.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | msgraphcore "github.com/microsoftgraph/msgraph-sdk-go-core" 8 | "github.com/microsoftgraph/msgraph-sdk-go/models" 9 | "github.com/microsoftgraph/msgraph-sdk-go/serviceprincipals" 10 | "github.com/webdevops/go-common/utils/to" 11 | ) 12 | 13 | // mgServicePrincipalByDisplayName fetches one servicePrincipal from MsGraph API using displayName 14 | func (e *AzureTemplateExecutor) mgServicePrincipalByDisplayName(displayName string) (interface{}, error) { 15 | e.logger.Info(`fetching MsGraph servicePrincipal by displayName`, slog.String("displayName", displayName)) 16 | 17 | if val, enabled := e.lintResult(); enabled { 18 | return val, nil 19 | } 20 | 21 | cacheKey := generateCacheKey(`mgServicePrincipalByDisplayName`, displayName) 22 | return e.cacheResult(cacheKey, func() (interface{}, error) { 23 | requestOpts := &serviceprincipals.ServicePrincipalsRequestBuilderGetRequestConfiguration{ 24 | QueryParameters: &serviceprincipals.ServicePrincipalsRequestBuilderGetQueryParameters{ 25 | Filter: to.StringPtr(fmt.Sprintf(`displayName eq '%v'`, 26 | escapeMsGraphFilter(displayName))), 27 | }, 28 | } 29 | result, err := e.msGraphClient().ServiceClient().ServicePrincipals().Get(e.ctx, requestOpts) 30 | if err != nil { 31 | return nil, fmt.Errorf(`failed to query MsGraph servicePrincipal: %w`, err) 32 | } 33 | 34 | list, err := e.mgServicePrincipalCreateListFromResult(result) 35 | if err != nil { 36 | return nil, fmt.Errorf(`failed to query MsGraph servicePrincipal: %w`, err) 37 | } 38 | 39 | switch len(list) { 40 | case 0: 41 | return nil, nil 42 | case 1: 43 | return list[0], nil 44 | default: 45 | return nil, fmt.Errorf(`found more then one servicePrincipal '%v'`, displayName) 46 | } 47 | }) 48 | } 49 | 50 | // mgServicePrincipalList fetches list of servicePrincipals from MsGraph API using $filter query 51 | func (e *AzureTemplateExecutor) mgServicePrincipalList(filter string) (interface{}, error) { 52 | e.logger.Info(`fetching MsGraph servicePrincipal list with $filter`, slog.String("filter", filter)) 53 | 54 | if val, enabled := e.lintResult(); enabled { 55 | return val, nil 56 | } 57 | 58 | cacheKey := generateCacheKey(`mgServicePrincipalList`, filter) 59 | return e.cacheResult(cacheKey, func() (interface{}, error) { 60 | result, err := e.msGraphClient().ServiceClient().ServicePrincipals().Get(e.ctx, nil) 61 | if err != nil { 62 | return nil, fmt.Errorf(`failed to query MsGraph servicePrincipal: %w`, err) 63 | } 64 | 65 | list, err := e.mgServicePrincipalCreateListFromResult(result) 66 | if err != nil { 67 | return nil, fmt.Errorf(`failed to query MsGraph servicePrincipal: %w`, err) 68 | } 69 | 70 | return list, nil 71 | }) 72 | } 73 | 74 | func (e *AzureTemplateExecutor) mgServicePrincipalCreateListFromResult(result models.ServicePrincipalCollectionResponseable) (list []interface{}, err error) { 75 | pageIterator, pageIteratorErr := msgraphcore.NewPageIterator[models.ServicePrincipalable](result, e.msGraphClient().RequestAdapter(), models.CreateServicePrincipalCollectionResponseFromDiscriminatorValue) 76 | if pageIteratorErr != nil { 77 | return list, pageIteratorErr 78 | } 79 | 80 | iterateErr := pageIterator.Iterate(e.ctx, func(servicePrincipal models.ServicePrincipalable) bool { 81 | obj, serializeErr := e.mgSerializeObject(servicePrincipal) 82 | if serializeErr != nil { 83 | err = serializeErr 84 | return false 85 | } 86 | 87 | list = append(list, obj) 88 | return true 89 | }) 90 | if iterateErr != nil { 91 | return list, iterateErr 92 | } 93 | 94 | return 95 | } 96 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "os/exec" 10 | "time" 11 | 12 | "github.com/jessevdk/go-flags" 13 | "github.com/webdevops/go-common/azuresdk/azidentity" 14 | 15 | "github.com/webdevops/helm-azure-tpl/config" 16 | ) 17 | 18 | const ( 19 | Author = "webdevops.io" 20 | UserAgent = "helm-azure-tpl/" 21 | 22 | TermColumns = 80 23 | ) 24 | 25 | var ( 26 | argparser *flags.Parser 27 | 28 | azAccountInfo map[string]interface{} 29 | 30 | startTime time.Time 31 | 32 | // Git version information 33 | gitCommit = "" 34 | gitTag = "" 35 | buildDate = "" 36 | ) 37 | 38 | var ( 39 | opts config.Opts 40 | ) 41 | 42 | func main() { 43 | startTime = time.Now() 44 | initArgparser() 45 | initLogger() 46 | initAzureEnvironment() 47 | run() 48 | } 49 | 50 | func initArgparser() { 51 | var err error 52 | argparser = flags.NewParser(&opts, flags.Default) 53 | 54 | // check if run by helm 55 | if helmCmd := os.Getenv("HELM_BIN"); helmCmd != "" { 56 | if pluginName := os.Getenv("HELM_PLUGIN_NAME"); pluginName != "" { 57 | argparser.Name = fmt.Sprintf(`%v %v`, helmCmd, pluginName) 58 | } 59 | } 60 | _, err = argparser.Parse() 61 | 62 | // check if there is a parse error 63 | if err != nil { 64 | var flagsErr *flags.Error 65 | if ok := errors.As(err, &flagsErr); ok && flagsErr.Type == flags.ErrHelp { 66 | os.Exit(0) 67 | } else { 68 | fmt.Println() 69 | argparser.WriteHelp(os.Stdout) 70 | os.Exit(1) 71 | } 72 | } 73 | } 74 | 75 | func fetchAzAccountInfo() { 76 | cmd := exec.Command("az", "account", "show", "-o", "json") 77 | cmd.Stderr = os.Stderr 78 | 79 | accountInfo, err := cmd.Output() 80 | if err != nil { 81 | logger.Error(`unable to detect Azure TenantID via 'az account show'`, slog.Any("error", err)) 82 | os.Exit(1) 83 | } 84 | 85 | err = json.Unmarshal(accountInfo, &azAccountInfo) 86 | if err != nil { 87 | logger.Error(`unable to parse 'az account show' output`, slog.Any("error", err)) 88 | os.Exit(1) 89 | } 90 | 91 | // auto set azure tenant id 92 | if opts.Azure.Environment == nil || *opts.Azure.Environment == "" { 93 | // autodetect tenant 94 | if val, ok := azAccountInfo["environmentName"].(string); ok { 95 | logger.Info(`detected Azure Environment from 'az account show'`, slog.String("azureEnvironment", val)) 96 | opts.Azure.Environment = &val 97 | } 98 | } 99 | 100 | // auto set azure tenant id 101 | if opts.Azure.Tenant == nil || *opts.Azure.Tenant == "" { 102 | // autodetect tenant 103 | if val, ok := azAccountInfo["tenantId"].(string); ok { 104 | logger.Info(`detected Azure TenantID from 'az account show'`, slog.String("azureTenant", val)) 105 | opts.Azure.Tenant = &val 106 | } 107 | } 108 | 109 | setOsEnvIfUnset(azidentity.EnvAzureEnvironment, *opts.Azure.Environment) 110 | setOsEnvIfUnset(azidentity.EnvAzureTenantID, *opts.Azure.Tenant) 111 | } 112 | 113 | func initAzureEnvironment() { 114 | if opts.Azure.Environment == nil || *opts.Azure.Environment == "" { 115 | // autodetect tenant 116 | if val, ok := azAccountInfo["environmentName"].(string); ok { 117 | logger.Info(`detected Azure Environment '%v' from 'az account show'`, slog.String("azureEnvironment", val)) 118 | opts.Azure.Environment = &val 119 | } 120 | } 121 | 122 | if opts.Azure.Environment != nil { 123 | if err := os.Setenv(azidentity.EnvAzureEnvironment, *opts.Azure.Environment); err != nil { 124 | logger.Warn(`unable to set envvar AZURE_ENVIRONMENT`, slog.Any("error", err)) 125 | } 126 | } 127 | } 128 | 129 | func setOsEnvIfUnset(name, value string) { 130 | if envVal := os.Getenv(name); envVal == "" { 131 | if err := os.Setenv(name, value); err != nil { 132 | panic(err) 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT_NAME := $(shell basename $(CURDIR)) 2 | GIT_TAG := $(shell git describe --dirty --tags --always) 3 | GIT_COMMIT := $(shell git rev-parse --short HEAD) 4 | BUILD_DATE := $(shell TZ=UTC date '+%Y-%m-%dT%H:%M:%SZ') 5 | LDFLAGS := -X "main.gitTag=$(GIT_TAG)" -X "main.gitCommit=$(GIT_COMMIT)" -X "main.buildDate=$(BUILD_DATE)" -extldflags "-static" -s -w 6 | BUILDFLAGS := -trimpath 7 | 8 | FIRST_GOPATH := $(firstword $(subst :, ,$(shell go env GOPATH))) 9 | GOLANGCI_LINT_BIN := $(FIRST_GOPATH)/bin/golangci-lint 10 | 11 | .PHONY: all 12 | all: vendor build 13 | 14 | .PHONY: clean 15 | clean: 16 | git clean -Xfd . 17 | 18 | ####################################### 19 | # builds 20 | ####################################### 21 | 22 | .PHONY: vendor 23 | vendor: 24 | go mod tidy 25 | go mod vendor 26 | go mod verify 27 | 28 | .PHONY: build-all 29 | build-all: 30 | GOOS=linux GOARCH=${GOARCH} CGO_ENABLED=0 go build -ldflags '$(LDFLAGS)' $(BUILDFLAGS) -o '$(PROJECT_NAME)' . 31 | GOOS=darwin GOARCH=${GOARCH} CGO_ENABLED=0 go build -ldflags '$(LDFLAGS)' $(BUILDFLAGS) -o '$(PROJECT_NAME).darwin' . 32 | GOOS=windows GOARCH=${GOARCH} CGO_ENABLED=0 go build -ldflags '$(LDFLAGS)' $(BUILDFLAGS) -o '$(PROJECT_NAME).exe' . 33 | 34 | .PHONY: build 35 | build: 36 | GOOS=${GOOS} GOARCH=${GOARCH} CGO_ENABLED=0 go build -ldflags '$(LDFLAGS)' $(BUILDFLAGS) -o $(PROJECT_NAME) . 37 | 38 | .PHONY: image 39 | image: image 40 | docker build -t $(PROJECT_NAME):$(GIT_TAG) . 41 | 42 | .PHONY: build-push-development 43 | build-push-development: 44 | docker buildx create --use 45 | docker buildx build -t webdevops/$(PROJECT_NAME):development --platform linux/amd64,linux/arm,linux/arm64 --push . 46 | 47 | ####################################### 48 | # quality checks 49 | ####################################### 50 | 51 | .PHONY: check 52 | check: vendor lint test 53 | 54 | .PHONY: test 55 | test: 56 | time go test ./... 57 | 58 | .PHONY: lint 59 | lint: $(GOLANGCI_LINT_BIN) 60 | time $(GOLANGCI_LINT_BIN) run --verbose --print-resources-usage 61 | 62 | $(GOLANGCI_LINT_BIN): 63 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(FIRST_GOPATH)/bin 64 | 65 | ####################################### 66 | # release assets 67 | ####################################### 68 | .PHONY: plugins 69 | plugins: plugin/azure-tpl plugin/azure-tpl-cli plugin/azure-tpl-getter 70 | 71 | plugin/%: $(SOURCE) 72 | echo 'package plugin $(call word-dot,$*,1)' 73 | mkdir -p ./release-assets/ 74 | helm plugin package 'plugins/$(call word-dot,$*,1)' -d ./release-assets --sign=false 75 | cp "./release-assets/$(call word-dot,$*,1)-"*.tgz "./release-assets/$(call word-dot,$*,1).tgz" 76 | 77 | ####################################### 78 | # release assets 79 | ####################################### 80 | 81 | RELEASE_ASSETS_LINUX = \ 82 | $(foreach GOARCH,amd64 arm64,\ 83 | release-assets/linux.$(GOARCH)) \ 84 | 85 | RELEASE_ASSETS_WINDOWS = \ 86 | $(foreach GOARCH,amd64 arm64,\ 87 | release-assets/windows.$(GOARCH)) \ 88 | 89 | RELEASE_ASSETS_DARWIN = \ 90 | $(foreach GOARCH,amd64 arm64,\ 91 | release-assets/darwin.$(GOARCH)) \ 92 | 93 | word-dot = $(word $2,$(subst ., ,$1)) 94 | 95 | .PHONY: release-assets 96 | release-assets: clean-release-assets vendor $(RELEASE_ASSETS_LINUX) $(RELEASE_ASSETS_DARWIN) $(RELEASE_ASSETS_WINDOWS) 97 | 98 | .PHONY: release-assets-linux 99 | release-assets-linux: $(RELEASE_ASSETS_LINUX) 100 | 101 | .PHONY: release-assets-darwin 102 | release-assets-darwin: $(RELEASE_ASSETS_DARWIN) 103 | 104 | .PHONY: release-assets-windows 105 | release-assets-windows: $(RELEASE_ASSETS_WINDOWS) 106 | 107 | .PHONY: clean-release-assets 108 | clean-release-assets: 109 | rm -rf ./release-assets 110 | mkdir -p ./release-assets 111 | 112 | release-assets/windows.%: $(SOURCE) 113 | echo 'build release-assets for windows/$(call word-dot,$*,2)' 114 | GOOS=windows \ 115 | GOARCH=$(call word-dot,$*,1) \ 116 | CGO_ENABLED=0 \ 117 | time go build -ldflags '$(LDFLAGS)' -o './release-assets/$(PROJECT_NAME).windows.$(call word-dot,$*,1).exe' . 118 | 119 | release-assets/%: $(SOURCE) 120 | echo 'build release-assets for $(call word-dot,$*,1)/$(call word-dot,$*,2)' 121 | GOOS=$(call word-dot,$*,1) \ 122 | GOARCH=$(call word-dot,$*,2) \ 123 | CGO_ENABLED=0 \ 124 | time go build -ldflags '$(LDFLAGS)' -o './release-assets/$(PROJECT_NAME).$(call word-dot,$*,1).$(call word-dot,$*,2)' . 125 | -------------------------------------------------------------------------------- /azuretpl/azure.appconfig.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net/url" 9 | "strings" 10 | 11 | "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" 12 | "github.com/webdevops/go-common/azuresdk/cloudconfig" 13 | "github.com/webdevops/go-common/utils/to" 14 | 15 | "github.com/webdevops/helm-azure-tpl/azuretpl/models" 16 | ) 17 | 18 | // buildAppConfigUrl builds Azure AppConfig url in case value is supplied as AppConfig name only 19 | func (e *AzureTemplateExecutor) buildAppConfigUrl(appConfigUrl string) (string, error) { 20 | // do not build keyvault url in lint mode 21 | if e.LintMode { 22 | return appConfigUrl, nil 23 | } 24 | 25 | // vault url generation (if only vault name is specified) 26 | if !strings.HasPrefix(strings.ToLower(appConfigUrl), "https://") { 27 | switch cloudName := e.azureClient().GetCloudName(); cloudName { 28 | case cloudconfig.AzurePublicCloud: 29 | appConfigUrl = fmt.Sprintf(`https://%s.azconfig.io`, appConfigUrl) 30 | case cloudconfig.AzureChinaCloud: 31 | appConfigUrl = fmt.Sprintf(`https://%s.azconfig.azure.cn`, appConfigUrl) 32 | case cloudconfig.AzureGovernmentCloud: 33 | appConfigUrl = fmt.Sprintf(`https://%s.azconfig.azure.us`, appConfigUrl) 34 | default: 35 | return appConfigUrl, fmt.Errorf(`cannot build Azure AppConfig url for "%s" and Azure cloud "%s", please use full url`, appConfigUrl, cloudName) 36 | } 37 | } 38 | 39 | // improve caching by removing trailing slash 40 | appConfigUrl = strings.TrimSuffix(appConfigUrl, "/") 41 | 42 | return appConfigUrl, nil 43 | } 44 | 45 | // azAppConfigSetting fetches secret object from Azure KeyVault 46 | func (e *AzureTemplateExecutor) azAppConfigSetting(appConfigUrl string, settingName string, label string) (interface{}, error) { 47 | // azure keyvault url detection 48 | if val, err := e.buildAppConfigUrl(appConfigUrl); err == nil { 49 | appConfigUrl = val 50 | } else { 51 | return nil, err 52 | } 53 | 54 | e.logger.Info(`fetching AppConfig value`, slog.String("url", appConfigUrl), slog.String("setting", settingName)) 55 | 56 | if val, enabled := e.lintResult(); enabled { 57 | return val, nil 58 | } 59 | cacheKey := generateCacheKey(`azAppConfigSetting`, appConfigUrl, settingName, label) 60 | return e.cacheResult(cacheKey, func() (interface{}, error) { 61 | client, err := azappconfig.NewClient(appConfigUrl, e.azureClient().GetCred(), nil) 62 | if err != nil { 63 | return nil, fmt.Errorf(`failed to create appconfig client for instance "%v": %w`, appConfigUrl, err) 64 | } 65 | 66 | options := azappconfig.GetSettingOptions{} 67 | if label != "" { 68 | options.Label = to.StringPtr(label) 69 | } 70 | 71 | appConfigValue, err := client.GetSetting(e.ctx, settingName, &options) 72 | if err != nil { 73 | return nil, fmt.Errorf(`unable to fetch app setting value "%[2]v" from appconfig instance "%[1]v": %[3]w`, appConfigUrl, settingName, err) 74 | } 75 | 76 | switch to.String(appConfigValue.ContentType) { 77 | case "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8": 78 | keyVaultResult := struct { 79 | Uri string 80 | }{} 81 | 82 | data := to.String(appConfigValue.Value) 83 | if err := json.Unmarshal([]byte(data), &keyVaultResult); err != nil { 84 | return nil, fmt.Errorf(`unable to parse keyvault reference from app setting value "%[2]v" from appconfig instance "%[1]v": %[3]w`, appConfigUrl, settingName, err) 85 | } 86 | 87 | if keyVaultResult.Uri == "" { 88 | return nil, fmt.Errorf(`unable to parse keyvault reference from app setting value "%[2]v" from appconfig instance "%[1]v": %[3]w`, appConfigUrl, settingName, errors.New("keyvault uri is empty")) 89 | } 90 | 91 | keyVaultRefUrl, err := url.Parse(keyVaultResult.Uri) 92 | if err != nil { 93 | return nil, fmt.Errorf(`unable to parse keyvault reference from app setting value "%[2]v" from appconfig instance "%[1]v": %[3]w`, appConfigUrl, settingName, err) 94 | } 95 | 96 | vaultUrl := fmt.Sprintf(`https://%s`, keyVaultRefUrl.Host) 97 | vaultSecretPathParts := strings.Split(strings.TrimPrefix(keyVaultRefUrl.Path, "/"), "/") 98 | 99 | secretName := vaultSecretPathParts[1] 100 | secretVersion := "" 101 | if len(vaultSecretPathParts) >= 3 { 102 | secretVersion = vaultSecretPathParts[2] 103 | } 104 | 105 | secret, err := e.azKeyVaultSecret(vaultUrl, secretName, secretVersion) 106 | if err != nil { 107 | return nil, fmt.Errorf(`unable to fetch keyvault reference from app setting value "%[2]v" from appconfig instance "%[1]v": %[3]w`, appConfigUrl, settingName, err) 108 | } 109 | 110 | secretMap := secret.(map[string]interface{}) 111 | appConfigValue.Value = to.StringPtr(secretMap["value"].(string)) 112 | } 113 | 114 | return transformToInterface(models.NewAzAppconfigSettingFromReponse(appConfigValue)) 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/webdevops/helm-azure-tpl 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.25.4 6 | 7 | require ( 8 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 9 | github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0 10 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 11 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0 12 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventhub/armeventhub v1.3.0 13 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.2.0 14 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 15 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis v1.0.0 16 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 17 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 18 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 19 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 20 | github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 21 | github.com/BurntSushi/toml v1.5.0 22 | github.com/Masterminds/sprig/v3 v3.3.0 23 | github.com/PaesslerAG/jsonpath v0.1.1 24 | github.com/jessevdk/go-flags v1.6.1 25 | github.com/microsoft/kiota-abstractions-go v1.9.3 26 | github.com/microsoftgraph/msgraph-sdk-go v1.90.0 27 | github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 28 | github.com/patrickmn/go-cache v2.1.0+incompatible 29 | github.com/webdevops/go-common v0.0.0-20250926082156-62a5f37dcb13 30 | gopkg.in/yaml.v3 v3.0.1 31 | helm.sh/helm/v3 v3.19.2 32 | sigs.k8s.io/yaml v1.6.0 33 | ) 34 | 35 | require ( 36 | dario.cat/mergo v1.0.2 // indirect 37 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect 38 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect 39 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect 40 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect 41 | github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect 42 | github.com/KimMachineGun/automemlimit v0.7.5 // indirect 43 | github.com/Masterminds/goutils v1.1.1 // indirect 44 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 45 | github.com/PaesslerAG/gval v1.2.4 // indirect 46 | github.com/beorn7/perks v1.0.1 // indirect 47 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 48 | github.com/dustin/go-humanize v1.0.1 // indirect 49 | github.com/go-logr/logr v1.4.3 // indirect 50 | github.com/go-logr/stdr v1.2.2 // indirect 51 | github.com/golang-jwt/jwt/v5 v5.3.0 // indirect 52 | github.com/google/uuid v1.6.0 // indirect 53 | github.com/huandu/xstrings v1.5.0 // indirect 54 | github.com/kylelemons/godebug v1.1.0 // indirect 55 | github.com/lmittmann/tint v1.1.2 // indirect 56 | github.com/mattn/go-isatty v0.0.20 // indirect 57 | github.com/microsoft/kiota-authentication-azure-go v1.3.1 // indirect 58 | github.com/microsoft/kiota-http-go v1.5.4 // indirect 59 | github.com/microsoft/kiota-serialization-form-go v1.1.2 // indirect 60 | github.com/microsoft/kiota-serialization-json-go v1.1.2 // indirect 61 | github.com/microsoft/kiota-serialization-multipart-go v1.1.2 // indirect 62 | github.com/microsoft/kiota-serialization-text-go v1.1.3 // indirect 63 | github.com/mitchellh/copystructure v1.2.0 // indirect 64 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 65 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 66 | github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect 67 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 68 | github.com/pkg/errors v0.9.1 // indirect 69 | github.com/prometheus/client_golang v1.23.2 // indirect 70 | github.com/prometheus/client_model v0.6.2 // indirect 71 | github.com/prometheus/common v0.67.4 // indirect 72 | github.com/prometheus/procfs v0.19.2 // indirect 73 | github.com/remeh/sizedwaitgroup v1.0.0 // indirect 74 | github.com/shopspring/decimal v1.4.0 // indirect 75 | github.com/spf13/cast v1.10.0 // indirect 76 | github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8 // indirect 77 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect 78 | go.opentelemetry.io/otel v1.38.0 // indirect 79 | go.opentelemetry.io/otel/metric v1.38.0 // indirect 80 | go.opentelemetry.io/otel/trace v1.38.0 // indirect 81 | go.uber.org/automaxprocs v1.6.0 // indirect 82 | go.yaml.in/yaml/v2 v2.4.3 // indirect 83 | go.yaml.in/yaml/v3 v3.0.4 // indirect 84 | golang.org/x/crypto v0.45.0 // indirect 85 | golang.org/x/net v0.47.0 // indirect 86 | golang.org/x/sys v0.38.0 // indirect 87 | golang.org/x/text v0.31.0 // indirect 88 | google.golang.org/protobuf v1.36.10 // indirect 89 | k8s.io/apimachinery v0.34.2 // indirect 90 | k8s.io/klog/v2 v2.130.1 // indirect 91 | k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect 92 | ) 93 | -------------------------------------------------------------------------------- /azuretpl/azure.network.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "strings" 7 | 8 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork" 9 | "github.com/webdevops/go-common/azuresdk/armclient" 10 | "github.com/webdevops/go-common/utils/to" 11 | ) 12 | 13 | // azPublicIpAddress fetches ipAddress from Azure Public IP Address 14 | func (e *AzureTemplateExecutor) azPublicIpAddress(resourceID string) (interface{}, error) { 15 | e.logger.Info(`fetching Azure PublicIpAddress`, slog.String("resourceID", resourceID)) 16 | 17 | if val, enabled := e.lintResult(); enabled { 18 | return val, nil 19 | } 20 | 21 | cacheKey := generateCacheKey(`azPublicIpAddress`, resourceID) 22 | return e.cacheResult(cacheKey, func() (interface{}, error) { 23 | resourceInfo, err := armclient.ParseResourceId(resourceID) 24 | if err != nil { 25 | return nil, fmt.Errorf(`unable to parse Azure resourceID '%v': %w`, resourceID, err) 26 | } 27 | 28 | client, err := armnetwork.NewPublicIPAddressesClient(resourceInfo.Subscription, e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | pipAddress, err := client.Get(e.ctx, resourceInfo.ResourceGroup, resourceInfo.ResourceName, nil) 34 | if err != nil { 35 | return nil, fmt.Errorf(`unable to fetch Azure resource '%v': %w`, resourceID, err) 36 | } 37 | 38 | return to.String(pipAddress.Properties.IPAddress), nil 39 | }) 40 | } 41 | 42 | // azPublicIpPrefixAddressPrefix fetches ipAddress prefix from Azure Public IP Address prefix 43 | func (e *AzureTemplateExecutor) azPublicIpPrefixAddressPrefix(resourceID string) (interface{}, error) { 44 | e.logger.Info(`fetching Azure PublicIpPrefix`, slog.String("resourceID", resourceID)) 45 | 46 | if val, enabled := e.lintResult(); enabled { 47 | return val, nil 48 | } 49 | 50 | cacheKey := generateCacheKey(`azPublicIpPrefixAddressPrefix`, resourceID) 51 | return e.cacheResult(cacheKey, func() (interface{}, error) { 52 | resourceInfo, err := armclient.ParseResourceId(resourceID) 53 | if err != nil { 54 | return nil, fmt.Errorf(`unable to parse Azure resourceID '%v': %w`, resourceID, err) 55 | } 56 | 57 | client, err := armnetwork.NewPublicIPPrefixesClient(resourceInfo.Subscription, e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | pipAddress, err := client.Get(e.ctx, resourceInfo.ResourceGroup, resourceInfo.ResourceName, nil) 63 | if err != nil { 64 | return nil, fmt.Errorf(`unable to fetch Azure resource '%v': %w`, resourceID, err) 65 | } 66 | 67 | return to.String(pipAddress.Properties.IPPrefix), nil 68 | }) 69 | } 70 | 71 | // azVirtualNetworkAddressPrefixes fetches ipAddress prefixes (array) from Azure VirtualNetwork 72 | func (e *AzureTemplateExecutor) azVirtualNetworkAddressPrefixes(resourceID string) (interface{}, error) { 73 | e.logger.Info(`fetching AddressPrefixes from Azure VirtualNetwork '%v'`, slog.String("resourceID", resourceID)) 74 | 75 | if val, enabled := e.lintResult(); enabled { 76 | return val, nil 77 | } 78 | 79 | cacheKey := generateCacheKey(`azVirtualNetworkAddressPrefixes`, resourceID) 80 | return e.cacheResult(cacheKey, func() (interface{}, error) { 81 | resourceInfo, err := armclient.ParseResourceId(resourceID) 82 | if err != nil { 83 | return nil, fmt.Errorf(`unable to parse Azure resourceID '%v': %w`, resourceID, err) 84 | } 85 | 86 | client, err := armnetwork.NewVirtualNetworksClient(resourceInfo.Subscription, e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | vnet, err := client.Get(e.ctx, resourceInfo.ResourceGroup, resourceInfo.ResourceName, nil) 92 | if err != nil { 93 | return nil, fmt.Errorf(`unable to fetch Azure resource '%v': %w`, resourceID, err) 94 | } 95 | 96 | if vnet.Properties.AddressSpace != nil { 97 | return to.Slice(vnet.Properties.AddressSpace.AddressPrefixes), nil 98 | } 99 | return []string{}, nil 100 | }) 101 | } 102 | 103 | // azVirtualNetworkSubnetAddressPrefixes fetches ipAddress prefixes (array) from Azure VirtualNetwork subnet 104 | func (e *AzureTemplateExecutor) azVirtualNetworkSubnetAddressPrefixes(resourceID string, subnetName string) (interface{}, error) { 105 | e.logger.Info(`fetching AddressPrefixes from Azure VirtualNetwork`, slog.String("resourceID", resourceID), slog.String("subnet", subnetName)) 106 | 107 | if val, enabled := e.lintResult(); enabled { 108 | return val, nil 109 | } 110 | 111 | cacheKey := generateCacheKey(`azVirtualNetworkSubnetAddressPrefixes`, resourceID, subnetName) 112 | return e.cacheResult(cacheKey, func() (interface{}, error) { 113 | resourceInfo, err := armclient.ParseResourceId(resourceID) 114 | if err != nil { 115 | return nil, fmt.Errorf(`unable to parse Azure resourceID '%v': %w`, resourceID, err) 116 | } 117 | 118 | client, err := armnetwork.NewVirtualNetworksClient(resourceInfo.Subscription, e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 119 | if err != nil { 120 | return nil, err 121 | } 122 | 123 | vnet, err := client.Get(e.ctx, resourceInfo.ResourceGroup, resourceInfo.ResourceName, nil) 124 | if err != nil { 125 | return nil, fmt.Errorf(`unable to fetch Azure resource '%v': %w`, resourceID, err) 126 | } 127 | 128 | if vnet.Properties.Subnets != nil { 129 | for _, subnet := range vnet.Properties.Subnets { 130 | if strings.EqualFold(to.String(subnet.Name), subnetName) { 131 | if subnet.Properties.AddressPrefixes != nil { 132 | return to.Slice(subnet.Properties.AddressPrefixes), nil 133 | } else if subnet.Properties.AddressPrefix != nil { 134 | return []string{to.String(subnet.Properties.AddressPrefix)}, nil 135 | } 136 | } 137 | } 138 | } 139 | 140 | return nil, fmt.Errorf(`unable to find Azure VirtualNetwork '%v' subnet '%v'`, resourceID, subnetName) 141 | }) 142 | } 143 | -------------------------------------------------------------------------------- /azuretpl/helm.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright The Helm Authors. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | package azuretpl 15 | 16 | import ( 17 | "bytes" 18 | "encoding/json" 19 | "strings" 20 | 21 | "github.com/BurntSushi/toml" 22 | "sigs.k8s.io/yaml" 23 | goYaml "sigs.k8s.io/yaml/goyaml.v3" 24 | ) 25 | 26 | // toYAML takes an interface, marshals it to yaml, and returns a string. It will 27 | // always return a string, even on marshal error (empty string). 28 | // 29 | // This is designed to be called from a template. 30 | func toYAML(v interface{}) string { 31 | data, err := yaml.Marshal(v) 32 | if err != nil { 33 | // Swallow errors inside of a template. 34 | return "" 35 | } 36 | return strings.TrimSuffix(string(data), "\n") 37 | } 38 | 39 | // mustToYAML takes an interface, marshals it to yaml, and returns a string. 40 | // It will panic if there is an error. 41 | // 42 | // This is designed to be called from a template when need to ensure that the 43 | // output YAML is valid. 44 | func mustToYAML(v interface{}) string { 45 | data, err := yaml.Marshal(v) 46 | if err != nil { 47 | panic(err) 48 | } 49 | return strings.TrimSuffix(string(data), "\n") 50 | } 51 | 52 | func toYAMLPretty(v interface{}) string { 53 | var data bytes.Buffer 54 | encoder := goYaml.NewEncoder(&data) 55 | encoder.SetIndent(2) 56 | err := encoder.Encode(v) 57 | 58 | if err != nil { 59 | // Swallow errors inside of a template. 60 | return "" 61 | } 62 | return strings.TrimSuffix(data.String(), "\n") 63 | } 64 | 65 | // fromYAML converts a YAML document into a map[string]interface{}. 66 | // 67 | // This is not a general-purpose YAML parser, and will not parse all valid 68 | // YAML documents. Additionally, because its intended use is within templates 69 | // it tolerates errors. It will insert the returned error message string into 70 | // m["Error"] in the returned map. 71 | func fromYAML(str string) map[string]interface{} { 72 | m := map[string]interface{}{} 73 | 74 | if err := yaml.Unmarshal([]byte(str), &m); err != nil { 75 | m["Error"] = err.Error() 76 | } 77 | return m 78 | } 79 | 80 | // fromYAMLArray converts a YAML array into a []interface{}. 81 | // 82 | // This is not a general-purpose YAML parser, and will not parse all valid 83 | // YAML documents. Additionally, because its intended use is within templates 84 | // it tolerates errors. It will insert the returned error message string as 85 | // the first and only item in the returned array. 86 | func fromYAMLArray(str string) []interface{} { 87 | a := []interface{}{} 88 | 89 | if err := yaml.Unmarshal([]byte(str), &a); err != nil { 90 | a = []interface{}{err.Error()} 91 | } 92 | return a 93 | } 94 | 95 | // toTOML takes an interface, marshals it to toml, and returns a string. It will 96 | // always return a string, even on marshal error (empty string). 97 | // 98 | // This is designed to be called from a template. 99 | func toTOML(v interface{}) string { 100 | b := bytes.NewBuffer(nil) 101 | e := toml.NewEncoder(b) 102 | err := e.Encode(v) 103 | if err != nil { 104 | return err.Error() 105 | } 106 | return b.String() 107 | } 108 | 109 | // fromTOML converts a TOML document into a map[string]interface{}. 110 | // 111 | // This is not a general-purpose TOML parser, and will not parse all valid 112 | // TOML documents. Additionally, because its intended use is within templates 113 | // it tolerates errors. It will insert the returned error message string into 114 | // m["Error"] in the returned map. 115 | func fromTOML(str string) map[string]interface{} { 116 | m := make(map[string]interface{}) 117 | 118 | if err := toml.Unmarshal([]byte(str), &m); err != nil { 119 | m["Error"] = err.Error() 120 | } 121 | return m 122 | } 123 | 124 | // toJSON takes an interface, marshals it to json, and returns a string. It will 125 | // always return a string, even on marshal error (empty string). 126 | // 127 | // This is designed to be called from a template. 128 | func toJSON(v interface{}) string { 129 | data, err := json.Marshal(v) 130 | if err != nil { 131 | // Swallow errors inside of a template. 132 | return "" 133 | } 134 | return string(data) 135 | } 136 | 137 | // mustToJSON takes an interface, marshals it to json, and returns a string. 138 | // It will panic if there is an error. 139 | // 140 | // This is designed to be called from a template when need to ensure that the 141 | // output JSON is valid. 142 | func mustToJSON(v interface{}) string { 143 | data, err := json.Marshal(v) 144 | if err != nil { 145 | panic(err) 146 | } 147 | return string(data) 148 | } 149 | 150 | // fromJSON converts a JSON document into a map[string]interface{}. 151 | // 152 | // This is not a general-purpose JSON parser, and will not parse all valid 153 | // JSON documents. Additionally, because its intended use is within templates 154 | // it tolerates errors. It will insert the returned error message string into 155 | // m["Error"] in the returned map. 156 | func fromJSON(str string) map[string]interface{} { 157 | m := make(map[string]interface{}) 158 | 159 | if err := json.Unmarshal([]byte(str), &m); err != nil { 160 | m["Error"] = err.Error() 161 | } 162 | return m 163 | } 164 | 165 | // fromJSONArray converts a JSON array into a []interface{}. 166 | // 167 | // This is not a general-purpose JSON parser, and will not parse all valid 168 | // JSON documents. Additionally, because its intended use is within templates 169 | // it tolerates errors. It will insert the returned error message string as 170 | // the first and only item in the returned array. 171 | func fromJSONArray(str string) []interface{} { 172 | a := []interface{}{} 173 | 174 | if err := json.Unmarshal([]byte(str), &a); err != nil { 175 | a = []interface{}{err.Error()} 176 | } 177 | return a 178 | } 179 | -------------------------------------------------------------------------------- /process.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | "os" 10 | "path/filepath" 11 | "runtime" 12 | "strings" 13 | "time" 14 | 15 | yaml "gopkg.in/yaml.v3" 16 | "helm.sh/helm/v3/pkg/strvals" 17 | 18 | "github.com/webdevops/helm-azure-tpl/azuretpl" 19 | ) 20 | 21 | const ( 22 | CommandHelp = "help" 23 | CommandVersion = "version" 24 | CommandLint = "lint" 25 | CommandProcess = "apply" 26 | ) 27 | 28 | var ( 29 | templateData map[string]interface{} 30 | lintMode = false 31 | ) 32 | 33 | func run() { 34 | // init template data 35 | templateData = make(map[string]interface{}) 36 | 37 | lintMode := false 38 | switch opts.Args.Command { 39 | case CommandHelp: 40 | argparser.WriteHelp(os.Stdout) 41 | os.Exit(0) 42 | case CommandVersion: 43 | versionPayload := map[string]string{ 44 | "version": gitTag, 45 | "gitTag": gitTag, 46 | "gitCommit": gitCommit, 47 | "goVersion": runtime.Version(), 48 | "buildDate": buildDate, 49 | "os": runtime.GOOS, 50 | "arch": runtime.GOARCH, 51 | "compiler": runtime.Compiler, 52 | "author": Author, 53 | } 54 | 55 | version, _ := json.Marshal(versionPayload) // nolint: errcheck 56 | fmt.Println(string(version)) 57 | os.Exit(0) 58 | case CommandLint: 59 | lintMode = true 60 | fallthrough 61 | case CommandProcess: 62 | printAppHeader() 63 | initSystem() 64 | 65 | if lintMode { 66 | logger.Info("enabling lint mode, all functions are in dry mode") 67 | } 68 | 69 | if len(opts.Args.Files) == 0 { 70 | logger.Error(`no files specified as arguments`) 71 | os.Exit(1) 72 | } 73 | 74 | if err := readValuesFiles(); err != nil { 75 | logger.Error(err.Error()) 76 | os.Exit(1) 77 | } 78 | templateFileList := buildSourceTargetList() 79 | 80 | if !lintMode { 81 | logger.Info("detecting Azure account information") 82 | fetchAzAccountInfo() 83 | 84 | azAccountInfoJson, err := json.Marshal(azAccountInfo) 85 | if err == nil { 86 | logger.Info(string(azAccountInfoJson)) 87 | } 88 | } 89 | 90 | for _, templateFile := range templateFileList { 91 | if lintMode { 92 | templateFile.Lint() 93 | } else { 94 | templateFile.Apply() 95 | } 96 | } 97 | 98 | azuretpl.PostSummary(logger, opts) 99 | 100 | logger.With(slog.Duration("duration", time.Since(startTime))).Info("finished") 101 | default: 102 | fmt.Printf("invalid command '%v'\n", opts.Args.Command) 103 | fmt.Println() 104 | argparser.WriteHelp(os.Stdout) 105 | os.Exit(1) 106 | } 107 | } 108 | 109 | func printAppHeader() { 110 | logger.Info(fmt.Sprintf("%v v%s (%s; %s; by %v at %v)", argparser.Name, gitTag, gitCommit, runtime.Version(), Author, buildDate)) 111 | logger.Info(string(opts.GetJson())) 112 | } 113 | 114 | // borrowed from helm/helm 115 | // https://github.com/helm/helm/blob/main/pkg/cli/values/options.go 116 | // Apache License, Version 2.0 117 | func readValuesFiles() error { 118 | templateDataValues := map[string]interface{}{} 119 | 120 | for _, filePath := range opts.AzureTpl.ValuesFiles { 121 | currentMap := map[string]interface{}{} 122 | 123 | contextLogger := logger.With(slog.String(`path`, filePath)) 124 | 125 | contextLogger.Info("using .Values file") 126 | data, err := os.ReadFile(filePath) 127 | if err != nil { 128 | contextLogger.Error(`unable to read values file: %v`, slog.Any("error", err)) 129 | os.Exit(1) 130 | } 131 | err = yaml.Unmarshal(data, ¤tMap) 132 | if err != nil { 133 | logger.Error("error: %v", slog.Any("error", err)) 134 | os.Exit(1) 135 | } 136 | // Merge with the previous map 137 | templateDataValues = mergeMaps(templateDataValues, currentMap) 138 | } 139 | 140 | // User specified a value via --set-json 141 | for _, value := range opts.AzureTpl.JSONValues { 142 | if err := strvals.ParseJSON(value, templateDataValues); err != nil { 143 | return fmt.Errorf(`failed parsing --set-json data %s`, value) 144 | } 145 | } 146 | 147 | // User specified a value via --set 148 | for _, value := range opts.AzureTpl.Values { 149 | if err := strvals.ParseInto(value, templateDataValues); err != nil { 150 | return fmt.Errorf(`failed parsing --set data: %w`, err) 151 | } 152 | } 153 | 154 | // User specified a value via --set-string 155 | for _, value := range opts.AzureTpl.StringValues { 156 | if err := strvals.ParseIntoString(value, templateDataValues); err != nil { 157 | return fmt.Errorf(`failed parsing --set-string data: %w`, err) 158 | } 159 | } 160 | 161 | // User specified a value via --set-file 162 | for _, value := range opts.AzureTpl.FileValues { 163 | reader := func(rs []rune) (interface{}, error) { 164 | bytes, err := os.ReadFile(string(rs)) 165 | if err != nil { 166 | return nil, err 167 | } 168 | return string(bytes), err 169 | } 170 | if err := strvals.ParseIntoFile(value, templateDataValues, reader); err != nil { 171 | return fmt.Errorf(`failed parsing --set-file data: %w`, err) 172 | } 173 | } 174 | 175 | templateData["Values"] = templateDataValues 176 | 177 | if opts.Debug { 178 | fmt.Fprintln(os.Stderr) 179 | fmt.Fprintln(os.Stderr, strings.Repeat("-", TermColumns)) 180 | fmt.Fprintln(os.Stderr, "--- VALUES") 181 | fmt.Fprintln(os.Stderr, strings.Repeat("-", TermColumns)) 182 | values, _ := yaml.Marshal(templateData) 183 | fmt.Fprintln(os.Stderr, string(values)) 184 | } 185 | 186 | return nil 187 | } 188 | 189 | // borrowed from helm/helm 190 | // https://github.com/helm/helm/blob/main/pkg/cli/values/options.go 191 | // Apache License, Version 2.0 192 | func mergeMaps(a, b map[string]interface{}) map[string]interface{} { 193 | out := make(map[string]interface{}, len(a)) 194 | for k, v := range a { 195 | out[k] = v 196 | } 197 | for k, v := range b { 198 | if v, ok := v.(map[string]interface{}); ok { 199 | if bv, ok := out[k]; ok { 200 | if bv, ok := bv.(map[string]interface{}); ok { 201 | out[k] = mergeMaps(bv, v) 202 | continue 203 | } 204 | } 205 | } 206 | out[k] = v 207 | } 208 | return out 209 | } 210 | 211 | func buildSourceTargetList() (list []TemplateFile) { 212 | ctx := context.Background() 213 | 214 | for _, filePath := range opts.Args.Files { 215 | var targetPath string 216 | sourcePath := filePath 217 | 218 | // remove protocol prefix (when using helm downloader) 219 | sourcePath = strings.TrimPrefix(sourcePath, "azuretpl://") 220 | sourcePath = strings.TrimPrefix(sourcePath, "azure-tpl://") 221 | 222 | if strings.Contains(sourcePath, ":") { 223 | // explicit target path set in argument (source:target) 224 | parts := strings.SplitN(sourcePath, ":", 2) 225 | sourcePath = parts[0] 226 | targetPath = parts[1] 227 | } else { 228 | targetPath = sourcePath 229 | 230 | // target not set explicit 231 | if opts.Target.FileExt != nil { 232 | // remove file extension 233 | targetPath = strings.TrimSuffix(targetPath, filepath.Ext(targetPath)) 234 | // adds new file extension 235 | targetPath = fmt.Sprintf("%s%s", targetPath, *opts.Target.FileExt) 236 | } 237 | 238 | // automatic target path 239 | targetPath = fmt.Sprintf( 240 | "%s%s%s", 241 | opts.Target.Prefix, 242 | targetPath, 243 | opts.Target.Suffix, 244 | ) 245 | } 246 | 247 | sourcePath = filepath.Clean(sourcePath) 248 | targetPath = filepath.Clean(targetPath) 249 | 250 | contextLogger := logger.With(slog.String(`template`, sourcePath)) 251 | 252 | if !opts.Stdout { 253 | contextLogger = contextLogger.With(slog.String(`targetPath`, targetPath)) 254 | 255 | if targetPath == "" || targetPath == "." || targetPath == "/" { 256 | contextLogger.Error(`invalid path detected`, slog.String("path", targetPath)) 257 | os.Exit(1) 258 | } 259 | } 260 | 261 | if _, err := os.Stat(sourcePath); errors.Is(err, os.ErrNotExist) { 262 | logger.Error(err.Error()) 263 | os.Exit(1) 264 | } 265 | 266 | var templateBasePath string 267 | if opts.Template.BasePath != nil { 268 | templateBasePath = *opts.Template.BasePath 269 | } else { 270 | if val, err := filepath.Abs(sourcePath); err == nil { 271 | templateBasePath = filepath.Dir(val) 272 | } else { 273 | logger.Error(`unable to resolve file`, slog.Any("error", err)) 274 | os.Exit(1) 275 | } 276 | } 277 | 278 | list = append( 279 | list, 280 | TemplateFile{ 281 | Context: ctx, 282 | SourceFile: sourcePath, 283 | TargetFile: targetPath, 284 | TemplateBaseDir: templateBasePath, 285 | Logger: contextLogger, 286 | }, 287 | ) 288 | } 289 | 290 | return 291 | } 292 | -------------------------------------------------------------------------------- /azuretpl/azure.keyvault.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "regexp" 7 | "sort" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" 13 | "github.com/webdevops/go-common/azuresdk/cloudconfig" 14 | "github.com/webdevops/go-common/utils/to" 15 | 16 | "github.com/webdevops/helm-azure-tpl/azuretpl/models" 17 | ) 18 | 19 | // buildAzKeyVaulUrl builds Azure KeyVault url in case value is supplied as KeyVault name only 20 | func (e *AzureTemplateExecutor) buildAzKeyVaulUrl(vaultUrl string) (string, error) { 21 | // do not build keyvault url in lint mode 22 | if e.LintMode { 23 | return vaultUrl, nil 24 | } 25 | 26 | // vault url generation (if only vault name is specified) 27 | if !strings.HasPrefix(strings.ToLower(vaultUrl), "https://") { 28 | switch cloudName := e.azureClient().GetCloudName(); cloudName { 29 | case cloudconfig.AzurePublicCloud: 30 | vaultUrl = fmt.Sprintf(`https://%s.vault.azure.net`, vaultUrl) 31 | case cloudconfig.AzureChinaCloud: 32 | vaultUrl = fmt.Sprintf(`https://%s.vault.azure.cn`, vaultUrl) 33 | case cloudconfig.AzureGovernmentCloud: 34 | vaultUrl = fmt.Sprintf(`https://%s.vault.usgovcloudapi.net`, vaultUrl) 35 | default: 36 | return vaultUrl, fmt.Errorf(`cannot build Azure KeyVault url for "%s" and Azure cloud "%s", please use full url`, vaultUrl, cloudName) 37 | } 38 | } 39 | 40 | // improve caching by removing trailing slash 41 | vaultUrl = strings.TrimSuffix(vaultUrl, "/") 42 | 43 | return vaultUrl, nil 44 | } 45 | 46 | // azKeyVaultSecret fetches secret object from Azure KeyVault 47 | func (e *AzureTemplateExecutor) azKeyVaultSecret(vaultUrl string, secretName string, opts ...string) (interface{}, error) { 48 | // azure keyvault url detection 49 | if val, err := e.buildAzKeyVaulUrl(vaultUrl); err == nil { 50 | vaultUrl = val 51 | } else { 52 | return nil, err 53 | } 54 | 55 | e.logger.Info(`fetching Azure KeyVault secret`, slog.String("keyvault", vaultUrl), slog.String("secret", secretName)) 56 | 57 | if val, enabled := e.lintResult(); enabled { 58 | return val, nil 59 | } 60 | cacheKey := generateCacheKey(`azKeyVaultSecret`, vaultUrl, secretName, strings.Join(opts, ";")) 61 | return e.cacheResult(cacheKey, func() (interface{}, error) { 62 | secretClient, err := azsecrets.NewClient(vaultUrl, e.azureClient().GetCred(), nil) 63 | if err != nil { 64 | return nil, fmt.Errorf(`failed to create keyvault client for vault "%v": %w`, vaultUrl, err) 65 | } 66 | 67 | version := "" 68 | if len(opts) == 1 { 69 | version = opts[0] 70 | } 71 | 72 | secret, err := secretClient.GetSecret(e.ctx, secretName, version, nil) 73 | if err != nil { 74 | return nil, fmt.Errorf(`unable to fetch secret "%[2]v" from vault "%[1]v": %[3]w`, vaultUrl, secretName, err) 75 | } 76 | 77 | e.addSummaryKeyvaultSecret(vaultUrl, secret) 78 | 79 | if !*secret.Attributes.Enabled { 80 | return nil, fmt.Errorf(`unable to use Azure KeyVault secret '%v' -> '%v': secret is disabled`, vaultUrl, secretName) 81 | } 82 | 83 | if secret.Attributes.NotBefore != nil && time.Now().Before(*secret.Attributes.NotBefore) { 84 | return nil, fmt.Errorf(`unable to use Azure KeyVault secret '%v' -> '%v': secret is not yet active (notBefore: %v)`, vaultUrl, secretName, secret.Attributes.NotBefore.Format(time.RFC3339)) 85 | } 86 | 87 | if secret.Attributes.Expires != nil { 88 | // secret has expiry date, let's check it 89 | 90 | if time.Now().After(*secret.Attributes.Expires) { 91 | // secret is expired 92 | if !e.opts.Keyvault.IgnoreExpiry { 93 | return nil, fmt.Errorf(`unable to use Azure KeyVault secret '%v' -> '%v': secret is expired (expires: %v, set env AZURETPL_KEYVAULT_EXPIRY_IGNORE=1 to ignore)`, vaultUrl, secretName, secret.Attributes.Expires.Format(time.RFC3339)) 94 | } else { 95 | e.logger.Warn( 96 | e.handleCicdWarning( 97 | fmt.Errorf(`found expiring Azure KeyVault secret '%v' -> '%v': secret is expired, but env AZURETPL_KEYVAULT_EXPIRY_IGNORE=1 is active (expires: %v)`, vaultUrl, secretName, secret.Attributes.Expires.Format(time.RFC3339)), 98 | ), 99 | ) 100 | } 101 | } else if time.Now().Add(e.opts.Keyvault.ExpiryWarning).After(*secret.Attributes.Expires) { 102 | // secret is expiring soon 103 | e.logger.Warn( 104 | e.handleCicdWarning( 105 | fmt.Errorf(`found expiring Azure KeyVault secret '%v' -> '%v': secret is expiring soon (expires: %v)`, vaultUrl, secretName, secret.Attributes.Expires.Format(time.RFC3339)), 106 | ), 107 | ) 108 | 109 | e.addSummaryLine( 110 | "warnings", 111 | fmt.Sprintf( 112 | ` - found expiring Azure KeyVault secret '%v' -> '%v': secret is expiring soon (expires: %v)`, 113 | vaultUrl, secretName, secret.Attributes.Expires.Format(time.RFC3339), 114 | ), 115 | ) 116 | } 117 | } 118 | 119 | e.logger.Info(`using Azure KeyVault secret`, slog.String("keyvault", vaultUrl), slog.String("secret", secretName), slog.String("version", secret.ID.Version())) 120 | e.handleCicdMaskSecret(to.String(secret.Value)) 121 | 122 | return transformToInterface(models.NewAzSecretItem(secret.Secret)) 123 | }) 124 | } 125 | 126 | // azKeyVaultSecretVersions fetches older versions of one secret from Azure KeyVault 127 | func (e *AzureTemplateExecutor) azKeyVaultSecretVersions(vaultUrl string, secretName string, count int) (interface{}, error) { 128 | // azure keyvault url detection 129 | if val, err := e.buildAzKeyVaulUrl(vaultUrl); err == nil { 130 | vaultUrl = val 131 | } else { 132 | return nil, err 133 | } 134 | 135 | e.logger.Info(`fetching Azure KeyVault secret history`, slog.String("keyvault", vaultUrl), slog.String("secret", secretName), slog.Int("count", count)) 136 | 137 | if val, enabled := e.lintResult(); enabled { 138 | return val, nil 139 | } 140 | cacheKey := generateCacheKey(`azKeyVaultSecretHistory`, vaultUrl, secretName, strconv.Itoa(count)) 141 | return e.cacheResult(cacheKey, func() (interface{}, error) { 142 | secretClient, err := azsecrets.NewClient(vaultUrl, e.azureClient().GetCred(), nil) 143 | if err != nil { 144 | return nil, fmt.Errorf(`failed to create keyvault client for vault "%v": %w`, vaultUrl, err) 145 | } 146 | 147 | pager := secretClient.NewListSecretPropertiesVersionsPager(secretName, nil) 148 | 149 | // get secrets first 150 | secretList := []*azsecrets.SecretProperties{} 151 | for pager.More() { 152 | result, err := pager.NextPage(e.ctx) 153 | if err != nil { 154 | panic(err) 155 | } 156 | 157 | for _, secretVersion := range result.Value { 158 | if !*secretVersion.Attributes.Enabled { 159 | continue 160 | } 161 | 162 | secretList = append(secretList, secretVersion) 163 | } 164 | 165 | // WARNING: secrets are ordered by version instead of creation date 166 | // so we cannot limit paging to just a few pages as even the current secrets 167 | // could be on the next or last page. 168 | // this was an awful design decision from Azure not to order the entries by creation date. 169 | // so we have to get the full list of versions for filtering, 170 | // otherwise we might miss important entries. 171 | // luckily versions are limited to just 500 entries but it's still an awful trap. 172 | // the same applies to the Azure KeyVault secret listing in the Azure Portal, 173 | // it's a mess, horrible to debug and a trap for all developers. 174 | // 175 | // if count >= 0 && len(secretList) >= count { 176 | // break 177 | // } 178 | } 179 | 180 | // sort results 181 | sort.Slice(secretList, func(i, j int) bool { 182 | return secretList[i].Attributes.Created.UTC().After(secretList[j].Attributes.Created.UTC()) 183 | }) 184 | 185 | // process list 186 | ret := []interface{}{} 187 | for _, secretVersion := range secretList { 188 | secret, err := secretClient.GetSecret(e.ctx, secretVersion.ID.Name(), secretVersion.ID.Version(), nil) 189 | if err != nil { 190 | return nil, fmt.Errorf(`unable to fetch secret "%[2]v" with version "%[3]v" from vault "%[1]v": %[4]w`, vaultUrl, secretVersion.ID.Name(), secretVersion.ID.Version(), err) 191 | } 192 | 193 | e.addSummaryKeyvaultSecret(vaultUrl, secret) 194 | 195 | e.handleCicdMaskSecret(to.String(secret.Value)) 196 | 197 | if val, err := transformToInterface(models.NewAzSecretItem(secret.Secret)); err == nil { 198 | ret = append(ret, val) 199 | } else { 200 | return nil, err 201 | } 202 | 203 | if count >= 0 && len(ret) >= count { 204 | break 205 | } 206 | } 207 | 208 | return ret, nil 209 | }) 210 | } 211 | 212 | // azKeyVaultSecretList fetches secrets from Azure KeyVault 213 | func (e *AzureTemplateExecutor) azKeyVaultSecretList(vaultUrl string, secretNamePattern string) (interface{}, error) { 214 | // azure keyvault url detection 215 | if val, err := e.buildAzKeyVaulUrl(vaultUrl); err == nil { 216 | vaultUrl = val 217 | } else { 218 | return nil, err 219 | } 220 | 221 | e.logger.Info(`fetching Azure KeyVault secret list from vault`, slog.String("keyvault", vaultUrl)) 222 | 223 | secretNamePatternRegExp, err := regexp.Compile(secretNamePattern) 224 | if err != nil { 225 | return nil, fmt.Errorf(`unable to compile Regular Expression "%v": %w`, secretNamePattern, err) 226 | } 227 | 228 | if val, enabled := e.lintResult(); enabled { 229 | return val, nil 230 | } 231 | cacheKey := generateCacheKey(`azKeyVaultSecretList`, vaultUrl) 232 | list, err := e.cacheResult(cacheKey, func() (interface{}, error) { 233 | secretClient, err := azsecrets.NewClient(vaultUrl, e.azureClient().GetCred(), nil) 234 | if err != nil { 235 | return nil, fmt.Errorf(`failed to create keyvault client for vault "%v": %w`, vaultUrl, err) 236 | } 237 | 238 | pager := secretClient.NewListSecretPropertiesPager(nil) 239 | 240 | ret := map[string]interface{}{} 241 | for pager.More() { 242 | result, err := pager.NextPage(e.ctx) 243 | if err != nil { 244 | panic(err) 245 | } 246 | 247 | for _, secret := range result.Value { 248 | secretData, err := transformToInterface(models.NewAzSecretItemFromSecretproperties(*secret)) 249 | if err != nil { 250 | return nil, fmt.Errorf(`unable to transform KeyVault secret '%v': %w`, secret.ID.Name(), err) 251 | } 252 | ret[secret.ID.Name()] = secretData 253 | } 254 | } 255 | 256 | return transformToInterface(ret) 257 | }) 258 | if err != nil { 259 | return list, err 260 | } 261 | 262 | // filter list 263 | if secretList, ok := list.(map[string]interface{}); ok { 264 | ret := map[string]interface{}{} 265 | for secretName, secret := range secretList { 266 | if secretNamePatternRegExp.MatchString(secretName) { 267 | ret[secretName] = secret 268 | } 269 | } 270 | list = ret 271 | } 272 | 273 | return list, nil 274 | } 275 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2022 webdevops.io All Rights Reserved 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /azuretpl/command.go: -------------------------------------------------------------------------------- 1 | package azuretpl 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "log/slog" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | textTemplate "text/template" 14 | "time" 15 | 16 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" 17 | "github.com/Masterminds/sprig/v3" 18 | cache "github.com/patrickmn/go-cache" 19 | "github.com/webdevops/go-common/azuresdk/armclient" 20 | "github.com/webdevops/go-common/log/slogger" 21 | "github.com/webdevops/go-common/msgraphsdk/msgraphclient" 22 | 23 | "github.com/webdevops/helm-azure-tpl/azuretpl/models" 24 | ) 25 | 26 | type ( 27 | AzureTemplateExecutor struct { 28 | ctx context.Context 29 | logger *slogger.Logger 30 | 31 | cache *cache.Cache 32 | cacheTtl time.Duration 33 | 34 | opts models.Opts 35 | 36 | UserAgent string 37 | 38 | TemplateRootPath string 39 | TemplateRelPath string 40 | 41 | currentPath string 42 | 43 | LintMode bool 44 | 45 | azureCliAccountInfo map[string]interface{} 46 | } 47 | ) 48 | 49 | var ( 50 | azureClient *armclient.ArmClient 51 | msGraphClient *msgraphclient.MsGraphClient 52 | ) 53 | 54 | func New(ctx context.Context, opts models.Opts, logger *slogger.Logger) *AzureTemplateExecutor { 55 | e := &AzureTemplateExecutor{ 56 | ctx: ctx, 57 | logger: logger, 58 | opts: opts, 59 | 60 | cacheTtl: 15 * time.Minute, 61 | } 62 | e.init() 63 | return e 64 | } 65 | 66 | func (e *AzureTemplateExecutor) init() { 67 | e.cache = globalCache 68 | } 69 | 70 | func (e *AzureTemplateExecutor) azureClient() *armclient.ArmClient { 71 | var err error 72 | if azureClient == nil { 73 | azureClient, err = armclient.NewArmClientFromEnvironment(e.logger.Slog()) 74 | if err != nil { 75 | e.logger.Error(err.Error()) 76 | os.Exit(1) 77 | } 78 | 79 | azureClient.SetUserAgent(e.UserAgent) 80 | azureClient.UseAzCliAuth() 81 | if err := azureClient.LazyConnect(); err != nil { 82 | e.logger.Error(err.Error()) 83 | os.Exit(1) 84 | } 85 | } 86 | return azureClient 87 | } 88 | 89 | func (e *AzureTemplateExecutor) msGraphClient() *msgraphclient.MsGraphClient { 90 | var err error 91 | if msGraphClient == nil { 92 | // ensure azureclient init 93 | if azureClient == nil { 94 | e.azureClient() 95 | } 96 | 97 | msGraphClient, err = msgraphclient.NewMsGraphClientFromEnvironment(e.logger.Slog()) 98 | if err != nil { 99 | e.logger.Error(err.Error()) 100 | os.Exit(1) 101 | } 102 | 103 | msGraphClient.SetUserAgent(e.UserAgent) 104 | msGraphClient.UseAzCliAuth() 105 | } 106 | return msGraphClient 107 | } 108 | 109 | func (e *AzureTemplateExecutor) SetUserAgent(val string) { 110 | e.UserAgent = val 111 | } 112 | 113 | func (e *AzureTemplateExecutor) SetTemplateRootPath(val string) { 114 | path, err := filepath.Abs(filepath.Clean(val)) 115 | if err != nil { 116 | e.logger.Error(`invalid base path`, slog.String("path", val), slog.Any("error", err)) 117 | os.Exit(1) 118 | } 119 | e.TemplateRootPath = path 120 | } 121 | 122 | func (e *AzureTemplateExecutor) SetTemplateRelPath(val string) { 123 | path, err := filepath.Abs(filepath.Clean(val)) 124 | if err != nil { 125 | e.logger.Error(`invalid base path`, slog.String("path", val), slog.Any("error", err)) 126 | os.Exit(1) 127 | } 128 | e.TemplateRelPath = path 129 | } 130 | 131 | func (e *AzureTemplateExecutor) SetLintMode(val bool) { 132 | e.LintMode = val 133 | } 134 | 135 | func (e *AzureTemplateExecutor) TxtFuncMap(tmpl *textTemplate.Template) textTemplate.FuncMap { 136 | includedNames := make(map[string]int) 137 | 138 | funcMap := map[string]interface{}{ 139 | // azure 140 | `azResource`: e.azResource, 141 | `azResourceList`: e.azResourceList, 142 | `azManagementGroup`: e.azManagementGroup, 143 | `azManagementGroupSubscriptionList`: e.azManagementGroupSubscriptionList, 144 | `azSubscription`: e.azSubscription, 145 | `azSubscriptionList`: e.azSubscriptionList, 146 | `azPublicIpAddress`: e.azPublicIpAddress, 147 | `azPublicIpPrefixAddressPrefix`: e.azPublicIpPrefixAddressPrefix, 148 | `azVirtualNetworkAddressPrefixes`: e.azVirtualNetworkAddressPrefixes, 149 | `azVirtualNetworkSubnetAddressPrefixes`: e.azVirtualNetworkSubnetAddressPrefixes, 150 | `azAccountInfo`: e.azAccountInfo, 151 | 152 | // azure keyvault 153 | `azKeyVaultSecret`: e.azKeyVaultSecret, 154 | `azKeyVaultSecretVersions`: e.azKeyVaultSecretVersions, 155 | `azKeyVaultSecretList`: e.azKeyVaultSecretList, 156 | 157 | // azure redis 158 | `azRedisAccessKeys`: e.azRedisAccessKeys, 159 | 160 | // azure storageAccount 161 | `azStorageAccountAccessKeys`: e.azStorageAccountAccessKeys, 162 | `azStorageAccountContainerBlob`: e.azStorageAccountContainerBlob, 163 | 164 | // azure eventhub 165 | `azEventHubListByNamespace`: e.azEventHubListByNamespace, 166 | 167 | // azure app config 168 | `azAppConfigSetting`: e.azAppConfigSetting, 169 | 170 | // azure managedCluster 171 | `azManagedClusterUserCredentials`: e.azManagedClusterUserCredentials, 172 | 173 | // resourcegraph 174 | `azResourceGraphQuery`: e.azResourceGraphQuery, 175 | 176 | // rbac 177 | `azRoleDefinition`: e.azRoleDefinition, 178 | `azRoleDefinitionList`: e.azRoleDefinitionList, 179 | 180 | // msGraph 181 | `mgUserByUserPrincipalName`: e.mgUserByUserPrincipalName, 182 | `mgUserList`: e.mgUserList, 183 | `mgGroupByDisplayName`: e.mgGroupByDisplayName, 184 | `mgGroupList`: e.mgGroupList, 185 | `mgServicePrincipalByDisplayName`: e.mgServicePrincipalByDisplayName, 186 | `mgServicePrincipalList`: e.mgServicePrincipalList, 187 | `mgApplicationByDisplayName`: e.mgApplicationByDisplayName, 188 | `mgApplicationList`: e.mgApplicationList, 189 | 190 | // misc 191 | `jsonPath`: e.jsonPath, 192 | 193 | // time 194 | `fromUnixtime`: fromUnixtime, 195 | `toRFC3339`: toRFC3339, 196 | 197 | // borrowed from github.com/helm/helm 198 | "toToml": toTOML, 199 | "fromToml": fromTOML, 200 | "toYaml": toYAML, 201 | "mustToYaml": mustToYAML, 202 | "toYamlPretty": toYAMLPretty, 203 | "fromYaml": fromYAML, 204 | "fromYamlArray": fromYAMLArray, 205 | "toJson": toJSON, 206 | "mustToJson": mustToJSON, 207 | "fromJson": fromJSON, 208 | "fromJsonArray": fromJSONArray, 209 | 210 | // files 211 | "filesGet": e.filesGet, 212 | "filesGlob": e.filesGlob, 213 | 214 | "include": func(path string, data interface{}) (string, error) { 215 | sourcePath := e.fileMakePathAbs(path) 216 | 217 | if v, ok := includedNames[sourcePath]; ok { 218 | if v > recursionMaxNums { 219 | return "", fmt.Errorf(`too many recursions for inclusion of '%v'`, path) 220 | } 221 | includedNames[sourcePath]++ 222 | } else { 223 | includedNames[sourcePath] = 1 224 | } 225 | 226 | content, err := os.ReadFile(sourcePath) 227 | if err != nil { 228 | return "", fmt.Errorf(`unable to read file: %w`, err) 229 | } 230 | 231 | parsedContent, err := tmpl.Parse(string(content)) 232 | if err != nil { 233 | return "", fmt.Errorf(`unable to parse file: %w`, err) 234 | } 235 | 236 | var buf bytes.Buffer 237 | err = parsedContent.Execute(&buf, data) 238 | if err != nil { 239 | return "", fmt.Errorf("unable to process template:\n%w", err) 240 | } 241 | 242 | includedNames[sourcePath]-- 243 | return buf.String(), nil 244 | }, 245 | 246 | "required": func(message string, val interface{}) (interface{}, error) { 247 | if val == nil { 248 | if e.LintMode { 249 | // Don't fail on missing required values when linting 250 | e.logger.Info(fmt.Sprintf("[TPL::required] missing required value: %s", message)) 251 | return "", nil 252 | } 253 | return val, errors.New(message) 254 | } else if _, ok := val.(string); ok { 255 | if val == "" { 256 | if e.LintMode { 257 | // Don't fail on missing required values when linting 258 | e.logger.Info(fmt.Sprintf("[TPL::required] missing required value: %s", message)) 259 | return "", nil 260 | } 261 | return val, errors.New(message) 262 | } 263 | } 264 | return val, nil 265 | }, 266 | 267 | "fail": func(message string) (string, error) { 268 | if e.LintMode { 269 | // Don't fail when linting 270 | e.logger.Info(fmt.Sprintf("[TPL::fail] fail: %s", message)) 271 | return "", nil 272 | } 273 | return "", errors.New(message) 274 | }, 275 | } 276 | 277 | // automatic add legacy funcs 278 | tmp := map[string]interface{}{} 279 | for funcName, funcCallback := range funcMap { 280 | // azFunc -> azureFunc 281 | if strings.HasPrefix(funcName, "az") { 282 | tmp["azure"+strings.TrimPrefix(funcName, "az")] = funcCallback 283 | } 284 | 285 | // mgFunc -> msGraphFunc 286 | if strings.HasPrefix(funcName, "mg") { 287 | tmp["msGraph"+strings.TrimPrefix(funcName, "mg")] = funcCallback 288 | } 289 | 290 | tmp[funcName] = funcCallback 291 | } 292 | funcMap = tmp 293 | 294 | return funcMap 295 | } 296 | 297 | func (e *AzureTemplateExecutor) TxtTemplate(name string) *textTemplate.Template { 298 | tmpl := textTemplate.New(name).Funcs(sprig.TxtFuncMap()) 299 | tmpl = tmpl.Funcs(e.TxtFuncMap(tmpl)) 300 | 301 | if !e.LintMode { 302 | tmpl.Option("missingkey=error") 303 | } else { 304 | tmpl.Option("missingkey=zero") 305 | } 306 | 307 | return tmpl 308 | } 309 | 310 | func (e *AzureTemplateExecutor) Parse(path string, templateData interface{}, buf *strings.Builder) error { 311 | e.currentPath = path 312 | 313 | tmpl := e.TxtTemplate(path) 314 | 315 | content, err := os.ReadFile(path) 316 | if err != nil { 317 | return e.handleCicdError(fmt.Errorf(`unable to read file: '%w'`, err)) 318 | } 319 | 320 | parsedContent, err := tmpl.Parse(string(content)) 321 | if err != nil { 322 | return e.handleCicdError(fmt.Errorf(`unable to parse file: %w`, err)) 323 | } 324 | 325 | oldPwd, err := os.Getwd() 326 | if err != nil { 327 | return e.handleCicdError(err) 328 | } 329 | 330 | if err = os.Chdir(e.TemplateRootPath); err != nil { 331 | return e.handleCicdError(err) 332 | } 333 | 334 | if err = parsedContent.Execute(buf, templateData); err != nil { 335 | return fmt.Errorf(`unable to process template: '%w'`, err) 336 | } 337 | 338 | if err = os.Chdir(oldPwd); err != nil { 339 | return e.handleCicdError(err) 340 | } 341 | 342 | return nil 343 | } 344 | 345 | // lintResult checks if lint mode is active and returns example value 346 | func (e *AzureTemplateExecutor) lintResult() (interface{}, bool) { 347 | if e.LintMode { 348 | return nil, true 349 | } 350 | return nil, false 351 | } 352 | 353 | // cacheResult caches template function results (eg. Azure REST API resource information) 354 | func (e *AzureTemplateExecutor) cacheResult(cacheKey string, callback func() (interface{}, error)) (interface{}, error) { 355 | if val, ok := e.cache.Get(cacheKey); ok { 356 | e.logger.Info("found in cache", slog.String("cacheKey", cacheKey)) 357 | return val, nil 358 | } 359 | 360 | ret, err := callback() 361 | if err != nil { 362 | return nil, err 363 | } 364 | 365 | e.cache.SetDefault(cacheKey, ret) 366 | 367 | return ret, nil 368 | } 369 | 370 | // fetchAzureResource fetches json representation of Azure resource by resourceID and apiVersion 371 | func (e *AzureTemplateExecutor) fetchAzureResource(resourceID string, apiVersion string) (interface{}, error) { 372 | resourceInfo, err := armclient.ParseResourceId(resourceID) 373 | if err != nil { 374 | return nil, fmt.Errorf(`unable to parse Azure resourceID '%v': %w`, resourceID, err) 375 | } 376 | 377 | if val, enabled := e.lintResult(); enabled { 378 | return val, nil 379 | } 380 | 381 | client, err := armresources.NewClient(resourceInfo.Subscription, e.azureClient().GetCred(), e.azureClient().NewArmClientOptions()) 382 | if err != nil { 383 | return nil, err 384 | } 385 | 386 | resource, err := client.GetByID(e.ctx, resourceID, apiVersion, nil) 387 | if err != nil { 388 | return nil, fmt.Errorf(`unable to fetch Azure resource '%v': %w`, resourceID, err) 389 | } 390 | 391 | data, err := resource.MarshalJSON() 392 | if err != nil { 393 | return nil, fmt.Errorf(`unable to marshal Azure resource '%v': %w`, resourceID, err) 394 | } 395 | 396 | var resourceRawInfo map[string]interface{} 397 | err = json.Unmarshal(data, &resourceRawInfo) 398 | if err != nil { 399 | return nil, fmt.Errorf(`unable to unmarshal Azure resource '%v': %w`, resourceID, err) 400 | } 401 | 402 | return resourceRawInfo, nil 403 | } 404 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= 2 | dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= 3 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= 4 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= 5 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= 6 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= 7 | github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= 8 | github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= 9 | github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0 h1:uU4FujKFQAz31AbWOO3INV9qfIanHeIUSsGhRlcJJmg= 10 | github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0/go.mod h1:qr3M3Oy6V98VR0c5tCHKUpaeJTRQh6KYzJewRtFWqfc= 11 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= 12 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= 13 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 h1:Hp+EScFOu9HeCbeW8WU2yQPJd4gGwhMgKxWe+G6jNzw= 14 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0/go.mod h1:/pz8dyNQe+Ey3yBp/XuYz7oqX8YDNWVpPB0hH3XWfbc= 15 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0 h1:figxyQZXzZQIcP3njhC68bYUiTw45J8/SsHaLW8Ax0M= 16 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0/go.mod h1:TmlMW4W5OvXOmOyKNnor8nlMMiO1ctIyzmHme/VHsrA= 17 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventhub/armeventhub v1.3.0 h1:4hGvxD72TluuFIXVr8f4XkKZfqAa7Pj61t0jmQ7+kes= 18 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventhub/armeventhub v1.3.0/go.mod h1:TSH7DcFItwAufy0Lz+Ft2cyopExCpxbOxI5SkH4dRNo= 19 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.0.0 h1:lMW1lD/17LUA5z1XTURo7LcVG2ICBPlyMHjIUrcFZNQ= 20 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.0.0/go.mod h1:ceIuwmxDWptoW3eCqSXlnPsZFKh4X+R38dWPv7GS9Vs= 21 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= 22 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= 23 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= 24 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= 25 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.2.0 h1:akP6VpxJGgQRpDR1P462piz/8OhYLRCreDj48AyNabc= 26 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.2.0/go.mod h1:8wzvopPfyZYPaQUoKW87Zfdul7jmJMDfp/k7YY3oJyA= 27 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 h1:QM6sE5k2ZT/vI5BEe0r7mqjsUSnhVBFbOsVkEuaEfiA= 28 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0/go.mod h1:243D9iHbcQXoFUtgHJwL7gl2zx1aDuDMjvBZVGr2uW0= 29 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis v1.0.0 h1:nmpTBgRg1HynngFYICRhceC7s5dmbKN9fJ/XQz/UQ2I= 30 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis v1.0.0/go.mod h1:3yjiOtnkVociBTlF7UZrwAGfJrGaOCsvtVS4HzNajxQ= 31 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 h1:zLzoX5+W2l95UJoVwiyNS4dX8vHyQ6x2xRLoBBL9wMk= 32 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0/go.mod h1:wVEOJfGTj0oPAUGA1JuRAvz/lxXQsWW16axmHPP47Bk= 33 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= 34 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= 35 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= 36 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo= 37 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= 38 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= 39 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= 40 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0/go.mod h1:gpl+q95AzZlKVI3xSoseF9QPrypk0hQqBiJYeB/cR/I= 41 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= 42 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= 43 | github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4= 44 | github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8= 45 | github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= 46 | github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= 47 | github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= 48 | github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= 49 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 50 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 51 | github.com/KimMachineGun/automemlimit v0.7.5 h1:RkbaC0MwhjL1ZuBKunGDjE/ggwAX43DwZrJqVwyveTk= 52 | github.com/KimMachineGun/automemlimit v0.7.5/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= 53 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 54 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 55 | github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= 56 | github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 57 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 58 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 59 | github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= 60 | github.com/PaesslerAG/gval v1.2.4 h1:rhX7MpjJlcxYwL2eTTYIOBUyEKZ+A96T9vQySWkVUiU= 61 | github.com/PaesslerAG/gval v1.2.4/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= 62 | github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= 63 | github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk= 64 | github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= 65 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 66 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 67 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 68 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 69 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 70 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 71 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 72 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 73 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 74 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 75 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 76 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 77 | github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 78 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 79 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 80 | github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= 81 | github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 82 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 83 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 84 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 85 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 86 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 87 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 88 | github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= 89 | github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= 90 | github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= 91 | github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= 92 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 93 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 94 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 95 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 96 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 97 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 98 | github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= 99 | github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 100 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 101 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 102 | github.com/microsoft/kiota-abstractions-go v1.9.3 h1:cqhbqro+VynJ7kObmo7850h3WN2SbvoyhypPn8uJ1SE= 103 | github.com/microsoft/kiota-abstractions-go v1.9.3/go.mod h1:f06pl3qSyvUHEfVNkiRpXPkafx7khZqQEb71hN/pmuU= 104 | github.com/microsoft/kiota-authentication-azure-go v1.3.1 h1:AGta92S6IL1E6ZMDb8YYB7NVNTIFUakbtLKUdY5RTuw= 105 | github.com/microsoft/kiota-authentication-azure-go v1.3.1/go.mod h1:26zylt2/KfKwEWZSnwHaMxaArpbyN/CuzkbotdYXF0g= 106 | github.com/microsoft/kiota-http-go v1.5.4 h1:wSUmL1J+bTQlAWHjbRkSwr+SPAkMVYeYxxB85Zw0KFs= 107 | github.com/microsoft/kiota-http-go v1.5.4/go.mod h1:L+5Ri+SzwELnUcNA0cpbFKp/pBbvypLh3Cd1PR6sjx0= 108 | github.com/microsoft/kiota-serialization-form-go v1.1.2 h1:SD6MATqNw+Dc5beILlsb/D87C36HKC/Zw7l+N9+HY2A= 109 | github.com/microsoft/kiota-serialization-form-go v1.1.2/go.mod h1:m4tY2JT42jAZmgbqFwPy3zGDF+NPJACuyzmjNXeuHio= 110 | github.com/microsoft/kiota-serialization-json-go v1.1.2 h1:eJrPWeQ665nbjO0gsHWJ0Bw6V/ZHHU1OfFPaYfRG39k= 111 | github.com/microsoft/kiota-serialization-json-go v1.1.2/go.mod h1:deaGt7fjZarywyp7TOTiRsjfYiyWxwJJPQZytXwYQn8= 112 | github.com/microsoft/kiota-serialization-multipart-go v1.1.2 h1:1pUyA1QgIeKslQwbk7/ox1TehjlCUUT3r1f8cNlkvn4= 113 | github.com/microsoft/kiota-serialization-multipart-go v1.1.2/go.mod h1:j2K7ZyYErloDu7Kuuk993DsvfoP7LPWvAo7rfDpdPio= 114 | github.com/microsoft/kiota-serialization-text-go v1.1.3 h1:8z7Cebn0YAAr++xswVgfdxZjnAZ4GOB9O7XP4+r5r/M= 115 | github.com/microsoft/kiota-serialization-text-go v1.1.3/go.mod h1:NDSvz4A3QalGMjNboKKQI9wR+8k+ih8UuagNmzIRgTQ= 116 | github.com/microsoftgraph/msgraph-sdk-go v1.90.0 h1:ygVeWfGB8TMO4rTFxtrYueZmj1mLqtDKW5UZ4iJwczU= 117 | github.com/microsoftgraph/msgraph-sdk-go v1.90.0/go.mod h1:UdZWxbZiFvjPug9DYayD90JNiHjXyNRA39lEpcy3Kms= 118 | github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 h1:0SrIoFl7TQnMRrsi5TFaeNe0q8KO5lRzRp4GSCCL2So= 119 | github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0/go.mod h1:A1iXs+vjsRjzANxF6UeKv2ACExG7fqTwHHbwh1FL+EE= 120 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 121 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 122 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 123 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 124 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 125 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 126 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 127 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 128 | github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= 129 | github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= 130 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= 131 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 132 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 133 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 134 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 135 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 136 | github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= 137 | github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= 138 | github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 139 | github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 140 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 141 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 142 | github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= 143 | github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= 144 | github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= 145 | github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= 146 | github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E= 147 | github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= 148 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 149 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 150 | github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 151 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 152 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 153 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 154 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 155 | github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8 h1:gMBdYMTHt2mmTdXW8YfvRjRUZ0GhyGV+IqSH9H15bGw= 156 | github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8/go.mod h1:Z5KcoM0YLC7INlNhEezeIZ0TZNYf7WSNO0Lvah4DSeQ= 157 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 158 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 159 | github.com/webdevops/go-common v0.0.0-20250926082156-62a5f37dcb13 h1:fLcLGJrnLrZCZqAAlCFYCEhLH1ZJxrlp8G4AbCd6m68= 160 | github.com/webdevops/go-common v0.0.0-20250926082156-62a5f37dcb13/go.mod h1:7BghWBvHgrvYWqmotAsefDalPFpWl1gbCo0wYlTN6X4= 161 | go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= 162 | go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 163 | go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= 164 | go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= 165 | go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= 166 | go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= 167 | go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= 168 | go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= 169 | go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= 170 | go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= 171 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 172 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 173 | go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= 174 | go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= 175 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 176 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 177 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 178 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 179 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 180 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 181 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 182 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 183 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 184 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 185 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 186 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 187 | google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= 188 | google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 189 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 190 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 191 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 192 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 193 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 194 | helm.sh/helm/v3 v3.19.2 h1:psQjaM8aIWrSVEly6PgYtLu/y6MRSmok4ERiGhZmtUY= 195 | helm.sh/helm/v3 v3.19.2/go.mod h1:gX10tB5ErM+8fr7bglUUS/UfTOO8UUTYWIBH1IYNnpE= 196 | k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= 197 | k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= 198 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 199 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 200 | k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= 201 | k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 202 | sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= 203 | sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Helm plugin for Azure template processing 2 | 3 | Plugin for [Helm](https://github.com/helm/helm) to inject Azure information (subscriptions, resources, msgraph) and Azure KeyVault secrets. 4 | Also works as standalone executable outside of Helm. 5 | 6 | [![license](https://img.shields.io/github/license/webdevops/helm-azure-tpl.svg)](https://github.com/webdevops/helm-azure-tpl/blob/master/LICENSE) 7 | [![DockerHub](https://img.shields.io/badge/DockerHub-webdevops%2Fhelm--azure--tpl-blue)](https://hub.docker.com/r/webdevops/helm-azure-tpl/) 8 | [![Quay.io](https://img.shields.io/badge/Quay.io-webdevops%2Fhelm--azure--tpl-blue)](https://quay.io/repository/webdevops/helm-azure-tpl) 9 | [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/helm-azure-tpl)](https://artifacthub.io/packages/search?repo=helm-azure-tpl) 10 | 11 | ## Installation 12 | 13 | requires `sed` and `curl` for installation 14 | 15 | ```yaml 16 | ################################################# 17 | # Github workflows 18 | 19 | # Installation of latest version 20 | - uses: webdevops/setup-helm-azure-tpl@v1 21 | 22 | # Installation of specific version 23 | - uses: webdevops/setup-helm-azure-tpl@v1 24 | with: 25 | version: 0.63.9 26 | 27 | ``` 28 | 29 | ```bash 30 | ################################################# 31 | # Helm 4.x 32 | 33 | # Installation of latest version 34 | helm plugin install "https://github.com/webdevops/helm-azure-tpl/releases/latest/download/azure-tpl-cli.tgz" --verify=false 35 | helm plugin install "https://github.com/webdevops/helm-azure-tpl/releases/latest/download/azure-tpl-getter.tgz" --verify=false 36 | 37 | # Installation of specific version 38 | helm plugin install "https://github.com/webdevops/helm-azure-tpl/releases/download/0.63.9/azure-tpl-cli.tgz" --verify=false 39 | helm plugin install "https://github.com/webdevops/helm-azure-tpl/releases/download/0.63.9/azure-tpl-getter.tgz" --verify=false 40 | 41 | # Update 42 | # please do uninstall and install again, for now 43 | 44 | # Uninstall 45 | helm plugin uninstall azure-tpl-cli 46 | helm plugin uninstall azure-tpl-getter 47 | 48 | ################################################# 49 | # Helm 3.x (legacy support) 50 | 51 | # Installation of latest version 52 | helm plugin install https://github.com/webdevops/helm-azure-tpl.git 53 | 54 | # Installation of specific version 55 | helm plugin install https://github.com/webdevops/helm-azure-tpl.git --version=0.63.9 56 | 57 | # Update to latest version 58 | helm plugin update azure-tpl 59 | 60 | # Uninstall 61 | helm plugin uninstall azure-tpl 62 | ``` 63 | 64 | ## Usage 65 | 66 | ### Helm (downloader mode) 67 | 68 | you can use helm in "downloader" mode to process files eg: 69 | 70 | > [!CAUTION] 71 | > **DO NOT use azure-tpl functions in ``values.yaml`` files as these files are read again by helm without azure-tpl processing! Use different file names and paths.** 72 | 73 | ```gotemplate 74 | helm upgrade foobar123 -f azuretpl://config/values.yaml . 75 | ``` 76 | 77 | for additional values files for azure-tpl you can use environment variabels: 78 | 79 | ```gotemplate 80 | AZURETPL_VALUES=./path/to/azuretpl.yaml helm upgrade foobar123 -f azuretpl://config/values.yaml . 81 | ``` 82 | 83 | ### File processing (only Helm v3) 84 | 85 | `helm azure-tpl` uses AzureCLI authentication to talk to Azure 86 | 87 | Process one file and overwrite it: 88 | ``` 89 | helm azure-tpl apply template.tpl 90 | ``` 91 | 92 | Process one file and saves generated content as another file: 93 | ``` 94 | helm azure-tpl apply template.tpl:template.yaml 95 | ``` 96 | 97 | Processes all `.tpl` files and saves them as `.yaml` files 98 | ``` 99 | helm azure-tpl apply --target.fileext=.yaml *.tpl 100 | ``` 101 | 102 | General usage: 103 | ``` 104 | Usage: 105 | helm-azure-tpl [OPTIONS] command [files...] 106 | 107 | Application Options: 108 | --log.level=[trace|debug|info|warning|error] Log level (default: info) [$AZURETPL_LOG_LEVEL] 109 | --log.format=[logfmt|json] Log format (default: logfmt) [$AZURETPL_LOG_FORMAT] 110 | --log.source=[|short|file|full] Show source for every log message (useful for debugging and bug reports) 111 | [$AZURETPL_LOG_SOURCE] 112 | --log.color=[|auto|yes|no] Enable color for logs [$AZURETPL_LOG_COLOR] 113 | --log.time Show log time [$AZURETPL_LOG_TIME] 114 | --dry-run dry run, do not write any files [$AZURETPL_DRY_RUN] 115 | --debug debug run, print generated content to stdout (WARNING: can expose secrets!) 116 | [$HELMHELM_DEBUG_DEBUG] 117 | --stdout Print parsed content to stdout instead of file (logs will be written to stderr) 118 | [$AZURETPL_STDOUT] 119 | --template.basepath= sets custom base path (if empty, base path is set by base directory for each file. will 120 | be appended to all root paths inside templates) [$AZURETPL_TEMPLATE_BASEPATH] 121 | --target.prefix= adds this value as prefix to filename on save (not used if targetfile is specified in 122 | argument) [$AZURETPL_TARGET_PREFIX] 123 | --target.suffix= adds this value as suffix to filename on save (not used if targetfile is specified in 124 | argument) [$AZURETPL_TARGET_SUFFIX] 125 | --target.fileext= replaces file extension (or adds if empty) with this value (eg. '.yaml') 126 | [$AZURETPL_TARGET_FILEEXT] 127 | --keyvault.expiry.warningduration= warn before soon expiring Azure KeyVault entries (default: 168h) 128 | [$AZURETPL_KEYVAULT_EXPIRY_WARNING_DURATION] 129 | --keyvault.expiry.ignore ignore expiry date of Azure KeyVault entries and don't fail' 130 | [$AZURETPL_KEYVAULT_EXPIRY_IGNORE] 131 | --values= path to yaml files for .Values [$AZURETPL_VALUES] 132 | --set-json= set JSON values on the command line (can specify multiple or separate values with 133 | commas: key1=jsonval1,key2=jsonval2) 134 | --set= set values on the command line (can specify multiple or separate values with commas: 135 | key1=val1,key2=val2) 136 | --set-string= set STRING values on the command line (can specify multiple or separate values with 137 | commas: key1=val1,key2=val2) 138 | --set-file= set values from respective files specified via the command line (can specify multiple or 139 | separate values with commas: key1=path1,key2=path2) 140 | 141 | Help Options: 142 | -h, --help Show this help message 143 | 144 | Arguments: 145 | command: specifies what to do (help, version, lint, apply) 146 | files: list of files to process (will overwrite files, different target file can be specified 147 | as sourcefile:targetfile) 148 | ``` 149 | 150 | ## Build-in objects 151 | 152 | | Object | Description | 153 | |-----------|---------------------------------------------------------------------------------------------------------------| 154 | | `.Values` | Additional data can be passed via `--values=values.yaml` files which is available under `.Values` (like Helm) | 155 | 156 | ## Template functions 157 | 158 | ### Azure template functions 159 | 160 | > [!NOTE] 161 | > Functions can also be used starting with `azure` prefix instead of `az` 162 | 163 | | Function | Parameters | Description | 164 | |------------------------------------------|-----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 165 | | `azAccountInfo` | | Output of `az account show` | 166 | | `azManagementGroup` | `groupID` (string) | Fetches Azure managementGroup | 167 | | `azManagementGroupSubscriptionList` | `groupID` (string) | Fetches list of all subscriptions (recursive) inside an Azure managementGroup | 168 | | `azSubscription` | `subscriptionID` (string, optional) | Fetches Azure subscription (current selected one if `subscriptionID` is empty) | 169 | | `azSubscriptionList` | | Fetches list of all visible azure subscriptions | 170 | | `azResource` | `resourceID` (string), `apiVersion` (string) | Fetches Azure resource information (json representation, interface object) | 171 | | `azResourceList` | `scope` (string), `filter` (string, optional) | Fetches list of Azure resources and filters it by using [$filter](https://learn.microsoft.com/en-us/rest/api/resources/resources/list), scope can be subscription ID or resourceGroup ID (array, json representation, interface object) | 172 | | `azPublicIpAddress` | `resourceID` (string) | Fetches ip address from Azure Public IP | 173 | | `azPublicIpPrefixAddressPrefix` | `resourceID` (string) | Fetches ip address prefix from Azure Public IP prefix | 174 | | `azVirtualNetworkAddressPrefixes` | `resourceID` (string) | Fetches address prefix (string array) from Azure VirtualNetwork | 175 | | `azVirtualNetworkSubnetAddressPrefixes` | `resourceID` (string), `subnetName` (string) | Fetches address prefix (string array) from Azure VirtualNetwork subnet | 176 | 177 | ### Azure KeyVault functions 178 | | Function | Parameters | Description | 179 | |----------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------| 180 | | `azKeyVaultSecret` | `vaultUrl` (string), `secretName` (string), `version` (string, optional) | Fetches secret object from Azure KeyVault | 181 | | `azKeyVaultSecretVersions` | `vaultUrl` (string), `secretName` (string), `count` (integer) | Fetches the list of `count` secret versions (as array, excluding disabled secrets) from Azure KeyVault | 182 | | `azKeyVaultSecretList` | `vaultUrl` (string), `secretNamePattern` (string, regexp) | Fetche the list of secret objects (without secret value) from Azure KeyVault and filters list by regular expression secretNamePattern | 183 | 184 | response format: 185 | ```json 186 | { 187 | "attributes": { 188 | "created": 1620236104, 189 | "enabled": true, 190 | "exp": 1724593377, 191 | "nbf": 1661362977, 192 | "recoverableDays": 0, 193 | "recoveryLevel": "Purgeable", 194 | "updated": 1661449616 195 | }, 196 | "contentType": "...", 197 | "id": "https://xxx.vault.azure.net/secrets/xxx/xxxxxxxxxx", 198 | "managed": false, 199 | "name": "xxx", 200 | "tags": {}, 201 | "value": "...", 202 | "version": "xxxxxxxxxx" 203 | } 204 | ``` 205 | 206 | ### Azure ManagedCluster functions 207 | | Function | Parameters | Description | 208 | |-----------------------------------|-------------------------|------------------------------------------------| 209 | | `azManagedClusterUserCredentials` | `resourceID` (string) | Fetches managedCluster user credentials object | 210 | 211 | ### Azure Redis cache functions 212 | | Function | Parameters | Description | 213 | |---------------------------------|-----------------------------|-----------------------------------------------------| 214 | | `azRedisAccessKeys` | `resourceID` (string) | Fetches access keys from Azure Redis Cache as array | 215 | 216 | ### Azure StorageAccount functions 217 | | Function | Parameters | Description | 218 | |----------------------------------|-----------------------------|------------------------------------------------------------| 219 | | `azStorageAccountAccessKeys` | `resourceID` (string) | Fetches access keys from Azure StorageAccount as array | 220 | | `azStorageAccountContainerBlob` | `containerBlobUrl` (string) | Fetches container blob from Azure StorageAccount as string | 221 | 222 | ### Azure EventHub functions 223 | | Function | Parameters | Description | 224 | |----------------------------------|-----------------------------|--------------------------------------------------------------------------------| 225 | | `azEventHubListByNamespace` | `resourceID` (string) | Fetches list of EventHubs in an EventHub namespace (specified by `resourceID`) | 226 | 227 | ### Azure AppConfig functions 228 | | Function | Parameters | Description | 229 | |------------------------|--------------------------------------------------------------------|--------------------------------------------------------------------------------------| 230 | | `azAppConfigSetting` | `appConfigUrl` (string), `settingName` (string), `label` (string) | Fetches setting value from app configuration instance (resolves keyvault references) | 231 | 232 | response format: 233 | ```json 234 | { 235 | "contentType": "...", 236 | "eTag": "...", 237 | "isReadOnly": false, 238 | "key":" ...", 239 | "label": null, 240 | "lastModified": null, 241 | "syncToken": "...", 242 | "tags": {}, 243 | "value": "..." 244 | } 245 | 246 | ``` 247 | 248 | ### Azure RBAC functions 249 | | Function | Parameters | Description | 250 | |------------------------|----------------------------------------------|----------------------------------------------------------------------------------------------------------| 251 | | `azRoleDefinition` | `scope` (string), `roleName` (string) | Fetches Azure RoleDefinition using scope (eg `/subscriptions/xxx`) and roleName | 252 | | `azRoleDefinitionList` | `scope` (string), `filter` (string,optional) | Fetches list of Azure RoleDefinitions using scope (eg `/subscriptions/xxx`) and optional `$filter` query | 253 | 254 | ### Azure ResourceGraph functions 255 | | Function | Parameters | Description | 256 | |------------------------|------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 257 | | `azResourceGraphQuery` | `scope` (string or []string), `query` (string) | Executes Azure ResourceGraph query against selected subscription IDs or management group IDs (as string comma separated or string array)
Use "/providers/microsoft.management/managementgroups/" as prefix for each management group | 258 | 259 | > [!NOTE] 260 | > ManagementGroups must be defined with their resource ID `/providers/microsoft.management/managementgroups/{MANAGEMENT_GROUP_ID}`. 261 | > Subscriptions must either be defined by the subscription id or their resource id `/subscriptions/{SUBSCRIPTION_ID}`. 262 | 263 | ### MsGraph (AzureAD) functions 264 | 265 | > [!NOTE] 266 | > Functions can also be used starting with `msGraph` prefix instead of `mg` 267 | 268 | | Function | Parameters | Description | 269 | |-----------------------------------|------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| 270 | | `mgUserByUserPrincipalName` | `userPrincipalName` | Fetches one user by UserPrincipalName | 271 | | `mgUserList` | `filter` (string) | Fetches list of users based on [`$filter`](https://docs.microsoft.com/en-us/graph/filter-query-parameter#examples-using-the-filter-query-operator) query | 272 | | `mgGroupByDisplayName` | `displayName` (string) | Fetches one group by displayName | 273 | | `mgGroupList` | `filter` (string) | Fetches list of groups based on [`$filter`](https://docs.microsoft.com/en-us/graph/filter-query-parameter#examples-using-the-filter-query-operator) query | 274 | | `mgServicePrincipalByDisplayName` | `displayName` (string) | Fetches one serviceprincipal by displayName | 275 | | `mgServicePrincipalList` | `filter` (string) | Fetches list of servicePrincipals based on [`$filter`](https://docs.microsoft.com/en-us/graph/filter-query-parameter#examples-using-the-filter-query-operator) query | 276 | | `mgApplicationByDisplayName` | `displayName` (string) | Fetches one application by displayName | 277 | | `mgApplicationList` | `filter` (string) | Fetches list of applications based on [`$filter`](https://docs.microsoft.com/en-us/graph/filter-query-parameter#examples-using-the-filter-query-operator) query | 278 | 279 | ## Time template functions 280 | 281 | | Function | Parameters | Description | 282 | |----------------|--------------------------------|---------------------------------------------| 283 | | `fromUnixtime` | `timestamp` (int/float/string) | Converts unixtimestamp to Time object | 284 | | `toRFC3339` | `time` (time.Time) | Converts time object to RFC3339 time string | 285 | 286 | ## Misc template functions 287 | 288 | | Function | Parameters | Description | 289 | |-------------|---------------------|--------------------------------------------------------------------------------------| 290 | | `jsonPath` | `jsonPath` (string) | Fetches object information using jsonPath (useful to process `azureResource` output) | 291 | | `filesGet` | `path` (string) | Fetches content of file and returns content as string | 292 | | `filesGlob` | `pattern` (string) | Lists files using glob pattern | 293 | 294 | ```gotemplate 295 | 296 | {{ 297 | azResource 298 | "/subscriptions/d86bcf13-ddf7-45ea-82f1-6f656767a318/resourcegroups/k8s/providers/Microsoft.ContainerService/managedClusters/mblaschke" 299 | "2022-01-01" 300 | | jsonPath "$.properties.aadProfile" 301 | | toYaml 302 | }} 303 | 304 | ``` 305 | 306 | 307 | ### Helm template functions (borrowed from [helm project](https://github.com/helm/helm)) 308 | 309 | | Function | Parameters | Description | 310 | |-----------------|-------------------------------------|----------------------------------------------| 311 | | `include` | `path` (string), `data` (interface) | Parses and includes template file | 312 | | `required` | `message` (string) | Throws error if passed object/value is empty | 313 | | `fail` | `message` (string) | Throws error | 314 | | `toToml` | | Convert object to toml | 315 | | `fromToml` | | Convert toml to object | 316 | | `toYaml` | | Convert object to yaml | 317 | | `toYamlPretty` | | Convert object to yaml (pretty format) | 318 | | `mustToYaml` | | Convert object to yaml | 319 | | `fromYaml` | | Convert yaml to object | 320 | | `fromYamlArray` | | Convert yaml array to array | 321 | | `toJson` | | Convert object to json | 322 | | `mustToJson` | | Convert object to json | 323 | | `fromJson` | | Convert json to object | 324 | | `fromJsonArray` | | Convert json array to array | 325 | 326 | ## Sprig template functions 327 | 328 | [Sprig template functions](https://masterminds.github.io/sprig/) are also available 329 | 330 | 331 | ## Examples 332 | 333 | ```gotemplate 334 | 335 | ## Fetch resource as object and convert to yaml 336 | {{ azResource 337 | "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourcegroups/example-rg/providers/Microsoft.ContainerService/managedClusters/k8scluster" 338 | "2022-01-01" 339 | | toYaml 340 | }} 341 | 342 | ## Fetch resource as object, select .properties.aadProfile via jsonPath and convert to yaml 343 | {{ azResource 344 | "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourcegroups/example-rg/providers/Microsoft.ContainerService/managedClusters/k8scluster" 345 | "2022-01-01" 346 | | jsonPath "$.properties.aadProfile" 347 | | toYaml 348 | }} 349 | 350 | ## Fetches all resources from subscription 351 | {{ (azResourceList "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx") | toYaml }} 352 | 353 | ## Fetches all virtualNetwork resources from subscription 354 | {{ (azResourceList "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx" "resourceType eq 'Microsoft.Network/virtualNetworks'") | toYaml }} 355 | 356 | ## Fetches all resources from resourceGroup 357 | {{ (azResourceList "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourcegroups/example-rg") | toYaml }} 358 | 359 | ## Fetch Azure VirtualNetwork address prefixes 360 | {{ azVirtualNetworkAddressPrefixes 361 | "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourcegroups/example-rg/providers/Microsoft.Network/virtualNetworks/k8s-vnet" 362 | }}xxx/resourcegroups/example-rg/providers/Microsoft.Network/virtualNetworks/k8s-vnet" 363 | "default2" 364 | | join "," 365 | }} 366 | 367 | ## Fetch first storageaccount key 368 | {{ (index (azStorageAccountAccessKeys "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourceGroups/example-rg/providers/Microsoft.Storage/storageAccounts/foobar") 0).value }} 369 | 370 | ## fetch blob from storageaccount container 371 | {{ azureStorageAccountContainerBlob "https://foobar.blob.core.windows.net/examplecontainer/file.json" }} 372 | 373 | ## Fetch secret value from Azure KeyVault (using only name; only AzurePublicCloud, AzureChinaCloud and AzureGovernmentCloud) 374 | {{ (azKeyVaultSecret "examplevault" "secretname").value }} 375 | {{ (azKeyVaultSecret "examplevault" "secretname").attributes.exp | fromUnixtime | toRFC3339 }} 376 | 377 | ## Fetch secret value from Azure KeyVault (using full url) 378 | {{ (azKeyVaultSecret "https://examplevault.vault.azure.net/" "secretname").value }} 379 | 380 | ## Fetch current environmentName 381 | {{ azAccountInfo.environmentName }} 382 | 383 | ## Fetch current tenantId 384 | {{ azAccountInfo.tenantId }} 385 | 386 | ## Fetch current selected subscription displayName 387 | {{ azSubscription.displayName }} 388 | 389 | ## Fetch RoleDefinition id for "owner" role 390 | {{ (azRoleDefinition "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx" "Owner").name }} 391 | 392 | ## Executes ResourceGraph query and returns result as yaml 393 | {{ azResourceGraphQuery "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx" `resources | where resourceGroup contains "xxxx"` | toYaml }} 394 | or 395 | {{ `resources | where resourceGroup contains "xxxx"` | azResourceGraphQuery "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx" | toYaml }} 396 | 397 | ## Executes ResourceGraph query for Management Group and returns result as yaml 398 | {{ azResourceGraphQuery "/providers/microsoft.management/managementgroups/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx" `resources | where resourceGroup contains "xxxx"` | toYaml }} 399 | or 400 | {{ `resources | where resourceGroup contains "xxxx"` | azResourceGraphQuery "/providers/microsoft.management/managementgroups/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx" | toYaml }} 401 | 402 | ## Fetch kubeconfig from AKS managed cluster 403 | {{ (index (azManagedClusterUserCredentials "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourcegroups/example-rg/providers/Microsoft.ContainerService/managedClusters/foobar").kubeconfigs 0).value | b64dec }} 404 | 405 | 406 | ``` 407 | 408 | ## Experimental features 409 | 410 | | Env var | Description | 411 | |-----------------------------------|---------------------------------| 412 | | `AZURETPL_EXPERIMENTAL_SUMMARY=1` | Post CICD summary (only GitHub) | 413 | 414 | PS: some code is borrowed from [Helm](https://github.com/helm/helm) 415 | --------------------------------------------------------------------------------