mirror of
https://github.com/SAP/jenkins-library.git
synced 2024-12-12 10:55:20 +02:00
ADO - Vault Secret Rotation (#3084)
* Implemented vault secret rotation for ADO * Added tests * Fixed issues
This commit is contained in:
parent
3921c563c9
commit
d8d533b154
@ -2,11 +2,13 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
|
||||
"github.com/SAP/jenkins-library/pkg/ado"
|
||||
"github.com/SAP/jenkins-library/pkg/jenkins"
|
||||
"github.com/SAP/jenkins-library/pkg/vault"
|
||||
|
||||
@ -93,6 +95,7 @@ func runVaultRotateSecretID(utils vaultRotateSecretIDUtils) error {
|
||||
|
||||
if err = utils.UpdateSecretInStore(config, newSecretID); err != nil {
|
||||
log.Entry().WithError(err).Warnf("Could not write secret back to secret store %s", config.SecretStore)
|
||||
return err
|
||||
}
|
||||
log.Entry().Infof("Secret has been successfully updated in secret store %s", config.SecretStore)
|
||||
return nil
|
||||
@ -111,6 +114,25 @@ func writeVaultSecretIDToStore(config *vaultRotateSecretIdOptions, secretID stri
|
||||
credManager := jenkins.NewCredentialsManager(instance)
|
||||
credential := jenkins.StringCredentials{ID: config.VaultAppRoleSecretTokenCredentialsID, Secret: secretID}
|
||||
return jenkins.UpdateCredential(ctx, credManager, config.JenkinsCredentialDomain, credential)
|
||||
case "ado":
|
||||
adoBuildClient, err := ado.NewBuildClient(config.AdoOrganization, config.AdoPersonalAccessToken, config.AdoProject, config.AdoPipelineID)
|
||||
if err != nil {
|
||||
log.Entry().Warn("Could not write secret ID back to Azure DevOps")
|
||||
return err
|
||||
}
|
||||
variables := []ado.Variable{
|
||||
{
|
||||
Name: config.VaultAppRoleSecretTokenCredentialsID,
|
||||
Value: secretID,
|
||||
IsSecret: true,
|
||||
},
|
||||
}
|
||||
if err := adoBuildClient.UpdateVariables(variables); err != nil {
|
||||
log.Entry().Warn("Could not write secret ID back to Azure DevOps")
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("error: invalid secret store: %s", config.SecretStore)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -24,6 +24,10 @@ type vaultRotateSecretIdOptions struct {
|
||||
VaultServerURL string `json:"vaultServerUrl,omitempty"`
|
||||
VaultNamespace string `json:"vaultNamespace,omitempty"`
|
||||
DaysBeforeExpiry int `json:"daysBeforeExpiry,omitempty"`
|
||||
AdoOrganization string `json:"adoOrganization,omitempty"`
|
||||
AdoPersonalAccessToken string `json:"adoPersonalAccessToken,omitempty"`
|
||||
AdoProject string `json:"adoProject,omitempty"`
|
||||
AdoPipelineID int `json:"adoPipelineId,omitempty"`
|
||||
}
|
||||
|
||||
// VaultRotateSecretIdCommand Rotate vault AppRole Secret ID
|
||||
@ -58,6 +62,7 @@ func VaultRotateSecretIdCommand() *cobra.Command {
|
||||
log.RegisterSecret(stepConfig.JenkinsURL)
|
||||
log.RegisterSecret(stepConfig.JenkinsUsername)
|
||||
log.RegisterSecret(stepConfig.JenkinsToken)
|
||||
log.RegisterSecret(stepConfig.AdoPersonalAccessToken)
|
||||
|
||||
if len(GeneralConfig.HookConfig.SentryConfig.Dsn) > 0 {
|
||||
sentryHook := log.NewSentryHook(GeneralConfig.HookConfig.SentryConfig.Dsn, GeneralConfig.CorrelationID)
|
||||
@ -109,10 +114,14 @@ func addVaultRotateSecretIdFlags(cmd *cobra.Command, stepConfig *vaultRotateSecr
|
||||
cmd.Flags().StringVar(&stepConfig.JenkinsCredentialDomain, "jenkinsCredentialDomain", `_`, "The jenkins credential domain which should be used")
|
||||
cmd.Flags().StringVar(&stepConfig.JenkinsUsername, "jenkinsUsername", os.Getenv("PIPER_jenkinsUsername"), "The jenkins username")
|
||||
cmd.Flags().StringVar(&stepConfig.JenkinsToken, "jenkinsToken", os.Getenv("PIPER_jenkinsToken"), "The jenkins token")
|
||||
cmd.Flags().StringVar(&stepConfig.VaultAppRoleSecretTokenCredentialsID, "vaultAppRoleSecretTokenCredentialsId", os.Getenv("PIPER_vaultAppRoleSecretTokenCredentialsId"), "The Jenkins credential ID for the Vault AppRole Secret ID credential")
|
||||
cmd.Flags().StringVar(&stepConfig.VaultAppRoleSecretTokenCredentialsID, "vaultAppRoleSecretTokenCredentialsId", os.Getenv("PIPER_vaultAppRoleSecretTokenCredentialsId"), "The Jenkins credential ID or Azure DevOps variable name for the Vault AppRole Secret ID credential")
|
||||
cmd.Flags().StringVar(&stepConfig.VaultServerURL, "vaultServerUrl", os.Getenv("PIPER_vaultServerUrl"), "The URL for the Vault server to use")
|
||||
cmd.Flags().StringVar(&stepConfig.VaultNamespace, "vaultNamespace", os.Getenv("PIPER_vaultNamespace"), "The vault namespace that should be used (optional)")
|
||||
cmd.Flags().IntVar(&stepConfig.DaysBeforeExpiry, "daysBeforeExpiry", 15, "The amount of days before expiry until the secret ID gets rotated")
|
||||
cmd.Flags().StringVar(&stepConfig.AdoOrganization, "adoOrganization", os.Getenv("PIPER_adoOrganization"), "The Azure DevOps organization name")
|
||||
cmd.Flags().StringVar(&stepConfig.AdoPersonalAccessToken, "adoPersonalAccessToken", os.Getenv("PIPER_adoPersonalAccessToken"), "The Azure DevOps personal access token")
|
||||
cmd.Flags().StringVar(&stepConfig.AdoProject, "adoProject", os.Getenv("PIPER_adoProject"), "The Azure DevOps project ID. Project name also can be used")
|
||||
cmd.Flags().IntVar(&stepConfig.AdoPipelineID, "adoPipelineId", 0, "The Azure DevOps pipeline ID. Also called as definition ID")
|
||||
|
||||
cmd.MarkFlagRequired("vaultAppRoleSecretTokenCredentialsId")
|
||||
cmd.MarkFlagRequired("vaultServerUrl")
|
||||
@ -228,6 +237,48 @@ func vaultRotateSecretIdMetadata() config.StepData {
|
||||
Aliases: []config.Alias{},
|
||||
Default: 15,
|
||||
},
|
||||
{
|
||||
Name: "adoOrganization",
|
||||
ResourceRef: []config.ResourceReference{},
|
||||
Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"},
|
||||
Type: "string",
|
||||
Mandatory: false,
|
||||
Aliases: []config.Alias{},
|
||||
Default: os.Getenv("PIPER_adoOrganization"),
|
||||
},
|
||||
{
|
||||
Name: "adoPersonalAccessToken",
|
||||
ResourceRef: []config.ResourceReference{
|
||||
{
|
||||
Name: "",
|
||||
Paths: []string{"$(vaultPath)/jenkins", "$(vaultBasePath)/$(vaultPipelineName)/jenkins", "$(vaultBasePath)/GROUP-SECRETS/jenkins"},
|
||||
Type: "vaultSecret",
|
||||
},
|
||||
},
|
||||
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
|
||||
Type: "string",
|
||||
Mandatory: false,
|
||||
Aliases: []config.Alias{},
|
||||
Default: os.Getenv("PIPER_adoPersonalAccessToken"),
|
||||
},
|
||||
{
|
||||
Name: "adoProject",
|
||||
ResourceRef: []config.ResourceReference{},
|
||||
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
|
||||
Type: "string",
|
||||
Mandatory: false,
|
||||
Aliases: []config.Alias{},
|
||||
Default: os.Getenv("PIPER_adoProject"),
|
||||
},
|
||||
{
|
||||
Name: "adoPipelineId",
|
||||
ResourceRef: []config.ResourceReference{},
|
||||
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
|
||||
Type: "int",
|
||||
Mandatory: false,
|
||||
Aliases: []config.Alias{},
|
||||
Default: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
2
go.mod
2
go.mod
@ -27,6 +27,7 @@ require (
|
||||
github.com/google/go-containerregistry v0.1.3
|
||||
github.com/google/go-github/v32 v32.1.0
|
||||
github.com/google/uuid v1.1.2
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.6.7
|
||||
github.com/hashicorp/vault v1.7.2
|
||||
github.com/hashicorp/vault/api v1.1.0
|
||||
@ -38,6 +39,7 @@ require (
|
||||
github.com/magicsong/color-glog v0.0.1 // indirect
|
||||
github.com/magicsong/sonargo v0.0.1
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5
|
||||
github.com/motemen/go-nuts v0.0.0-20200601065735-3df31f16cb2f
|
||||
github.com/piper-validation/fortify-client-go v0.0.0-20210114140201-1261216783c6
|
||||
github.com/pkg/errors v0.9.1
|
||||
|
5
go.sum
5
go.sum
@ -731,8 +731,9 @@ github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYb
|
||||
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
|
||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gotestyourself/gotestyourself v2.2.0+incompatible h1:AQwinXlbQR2HvPjQZOmDhRqsv5mZf+Jb1RnSLxcqZcI=
|
||||
github.com/gotestyourself/gotestyourself v2.2.0+incompatible/go.mod h1:zZKM6oeNM8k+FRljX1mnzVYeS8wiGgQyvST1/GafPbY=
|
||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||
@ -1131,6 +1132,8 @@ github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1
|
||||
github.com/michaelklishin/rabbit-hole v0.0.0-20191008194146-93d9988f0cd5 h1:uA3b4GgZMZxAJsTkd+CVQ85b7KBlD7HLpd/FfTNlGN0=
|
||||
github.com/michaelklishin/rabbit-hole v0.0.0-20191008194146-93d9988f0cd5/go.mod h1:+pmbihVqjC3GPdfWv1V2TnRSuVvwrWLKfEP/MZVB/Wc=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 h1:YH424zrwLTlyHSH/GzLMJeu5zhYVZSx5RQxGKm1h96s=
|
||||
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5/go.mod h1:PoGiBqKSQK1vIfQ+yVaFcGjDySHvym6FM1cNYnwzbrY=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
|
||||
github.com/miekg/dns v1.1.40 h1:pyyPFfGMnciYUk/mXpKkVmeMQjfXqt3FAJ2hy7tPiLA=
|
||||
|
109
pkg/ado/ado.go
Normal file
109
pkg/ado/ado.go
Normal file
@ -0,0 +1,109 @@
|
||||
package ado
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/microsoft/azure-devops-go-api/azuredevops"
|
||||
"github.com/microsoft/azure-devops-go-api/azuredevops/build"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const azureUrl = "https://dev.azure.com"
|
||||
|
||||
type BuildClient interface {
|
||||
UpdateVariables(variables []Variable) error
|
||||
}
|
||||
|
||||
type BuildClientImpl struct {
|
||||
ctx context.Context
|
||||
buildClient build.Client
|
||||
project string
|
||||
pipelineID int
|
||||
}
|
||||
|
||||
type Variable struct {
|
||||
Name string
|
||||
Value string
|
||||
IsSecret bool
|
||||
AllowOverride bool
|
||||
}
|
||||
|
||||
//UpdateVariables updates variables in build definition or creates them if they are missing
|
||||
func (bc *BuildClientImpl) UpdateVariables(variables []Variable) error {
|
||||
if len(variables) == 0 {
|
||||
return errors.New("error: slice variables must not be empty")
|
||||
}
|
||||
getDefinitionArgs := build.GetDefinitionArgs{
|
||||
Project: &bc.project,
|
||||
DefinitionId: &bc.pipelineID,
|
||||
}
|
||||
|
||||
// Get a build definition
|
||||
buildDefinition, err := bc.buildClient.GetDefinition(bc.ctx, getDefinitionArgs)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "error: get definition failed")
|
||||
}
|
||||
|
||||
buildDefinitionVars := map[string]build.BuildDefinitionVariable{}
|
||||
if buildDefinition.Variables != nil {
|
||||
buildDefinitionVars = *buildDefinition.Variables
|
||||
}
|
||||
|
||||
for _, variable := range variables {
|
||||
buildDefinitionVars[variable.Name] = build.BuildDefinitionVariable{
|
||||
Value: &variable.Value,
|
||||
IsSecret: &variable.IsSecret,
|
||||
AllowOverride: &variable.AllowOverride,
|
||||
}
|
||||
}
|
||||
|
||||
buildDefinition.Variables = &buildDefinitionVars
|
||||
|
||||
updateDefinitionArgs := build.UpdateDefinitionArgs{
|
||||
Definition: buildDefinition,
|
||||
Project: &bc.project,
|
||||
DefinitionId: &bc.pipelineID,
|
||||
}
|
||||
|
||||
_, err = bc.buildClient.UpdateDefinition(bc.ctx, updateDefinitionArgs)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "error: update definition failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//NewBuildClient Create a client to interact with the Build area
|
||||
func NewBuildClient(organization string, personalAccessToken string, project string, pipelineID int) (BuildClient, error) {
|
||||
if organization == "" {
|
||||
return nil, errors.New("error: organization must not be empty")
|
||||
}
|
||||
if personalAccessToken == "" {
|
||||
return nil, errors.New("error: personal access token must not be empty")
|
||||
}
|
||||
if project == "" {
|
||||
return nil, errors.New("error: project must not be empty")
|
||||
}
|
||||
|
||||
organizationUrl := fmt.Sprintf("%s/%s", azureUrl, organization)
|
||||
// Create a connection to your organization
|
||||
connection := azuredevops.NewPatConnection(organizationUrl, personalAccessToken)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a client to interact with the Core area
|
||||
buildClient, err := build.NewClient(ctx, connection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buildClientImpl := &BuildClientImpl{
|
||||
ctx: ctx,
|
||||
buildClient: buildClient,
|
||||
project: project,
|
||||
pipelineID: pipelineID,
|
||||
}
|
||||
|
||||
return buildClientImpl, nil
|
||||
}
|
100
pkg/ado/ado_test.go
Normal file
100
pkg/ado/ado_test.go
Normal file
@ -0,0 +1,100 @@
|
||||
package ado
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/SAP/jenkins-library/pkg/ado/mocks"
|
||||
"github.com/microsoft/azure-devops-go-api/azuredevops/build"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestUpdateVariables(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
const secretName = "test-secret"
|
||||
const secretValue = "secret-value"
|
||||
const projectID = "some-id"
|
||||
const pipelineID = 1
|
||||
testErr := errors.New("error")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
variables []Variable
|
||||
getDefinitionError error
|
||||
updateDefinitionError error
|
||||
isErrorExpected bool
|
||||
errorStr string
|
||||
}{
|
||||
{
|
||||
name: "Test update secret - successful",
|
||||
variables: []Variable{{Name: secretName, Value: secretValue, IsSecret: true}},
|
||||
getDefinitionError: nil,
|
||||
updateDefinitionError: nil,
|
||||
isErrorExpected: false,
|
||||
},
|
||||
{
|
||||
name: "Failed get definition",
|
||||
variables: []Variable{{Name: secretName, Value: secretValue, IsSecret: true}},
|
||||
getDefinitionError: testErr,
|
||||
updateDefinitionError: nil,
|
||||
isErrorExpected: true,
|
||||
errorStr: "get definition failed",
|
||||
},
|
||||
{
|
||||
name: "Failed update definition",
|
||||
variables: []Variable{{Name: secretName, Value: secretValue, IsSecret: true}},
|
||||
getDefinitionError: nil,
|
||||
updateDefinitionError: testErr,
|
||||
isErrorExpected: true,
|
||||
errorStr: "update definition failed",
|
||||
},
|
||||
{
|
||||
name: "Slice variables is empty",
|
||||
variables: []Variable{},
|
||||
getDefinitionError: nil,
|
||||
updateDefinitionError: nil,
|
||||
isErrorExpected: true,
|
||||
errorStr: "slice variables must not be empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buildClientMock := &mocks.Client{}
|
||||
buildClientMock.On("GetDefinition", ctx, mock.Anything).Return(
|
||||
func(ctx context.Context, getDefinitionArgs build.GetDefinitionArgs) *build.BuildDefinition {
|
||||
return &build.BuildDefinition{}
|
||||
},
|
||||
func(ctx context.Context, getDefinitionArgs build.GetDefinitionArgs) error {
|
||||
return tt.getDefinitionError
|
||||
},
|
||||
)
|
||||
|
||||
buildClientMock.On("UpdateDefinition", ctx, mock.Anything).Return(
|
||||
func(ctx context.Context, updateDefinitionArgs build.UpdateDefinitionArgs) *build.BuildDefinition {
|
||||
return &build.BuildDefinition{}
|
||||
},
|
||||
func(ctx context.Context, updateDefinitionArgs build.UpdateDefinitionArgs) error {
|
||||
return tt.updateDefinitionError
|
||||
},
|
||||
)
|
||||
|
||||
buildClientImpl := BuildClientImpl{
|
||||
ctx: ctx,
|
||||
buildClient: buildClientMock,
|
||||
project: projectID,
|
||||
pipelineID: pipelineID,
|
||||
}
|
||||
|
||||
err := buildClientImpl.UpdateVariables(tt.variables)
|
||||
if tt.isErrorExpected {
|
||||
assert.Contains(t, err.Error(), tt.errorStr)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
1769
pkg/ado/mocks/Client.go
Normal file
1769
pkg/ado/mocks/Client.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,7 @@ spec:
|
||||
default: "jenkins"
|
||||
possibleValues:
|
||||
- jenkins
|
||||
- ado
|
||||
- name: jenkinsUrl
|
||||
type: string
|
||||
description: "The jenkins url"
|
||||
@ -73,7 +74,7 @@ spec:
|
||||
- $(vaultBasePath)/GROUP-SECRETS/jenkins
|
||||
- name: vaultAppRoleSecretTokenCredentialsId
|
||||
type: string
|
||||
description: The Jenkins credential ID for the Vault AppRole Secret ID credential
|
||||
description: The Jenkins credential ID or Azure DevOps variable name for the Vault AppRole Secret ID credential
|
||||
scope:
|
||||
- GENERAL
|
||||
- PARAMETERS
|
||||
@ -105,3 +106,39 @@ spec:
|
||||
- STAGES
|
||||
- STEPS
|
||||
default: 15
|
||||
- name: adoOrganization
|
||||
type: string
|
||||
scope:
|
||||
- GENERAL
|
||||
- PARAMETERS
|
||||
- STAGES
|
||||
- STEPS
|
||||
description: The Azure DevOps organization name
|
||||
- name: adoPersonalAccessToken
|
||||
type: string
|
||||
scope:
|
||||
- PARAMETERS
|
||||
- STAGES
|
||||
- STEPS
|
||||
description: The Azure DevOps personal access token
|
||||
secret: true
|
||||
resourceRef:
|
||||
- type: vaultSecret
|
||||
paths:
|
||||
- $(vaultPath)/jenkins
|
||||
- $(vaultBasePath)/$(vaultPipelineName)/jenkins
|
||||
- $(vaultBasePath)/GROUP-SECRETS/jenkins
|
||||
- name: adoProject
|
||||
type: string
|
||||
scope:
|
||||
- PARAMETERS
|
||||
- STAGES
|
||||
- STEPS
|
||||
description: The Azure DevOps project ID. Project name also can be used
|
||||
- name: adoPipelineId
|
||||
type: int
|
||||
scope:
|
||||
- PARAMETERS
|
||||
- STAGES
|
||||
- STEPS
|
||||
description: The Azure DevOps pipeline ID. Also called as definition ID
|
||||
|
Loading…
Reference in New Issue
Block a user