Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add script mode support to windows tasks #4128

Merged
merged 1 commit into from
Sep 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ var (
gitImage = flag.String("git-image", "", "The container image containing our Git binary.")
kubeconfigWriterImage = flag.String("kubeconfig-writer-image", "", "The container image containing our kubeconfig writer binary.")
shellImage = flag.String("shell-image", "", "The container image containing a shell")
shellImageWin = flag.String("shell-image-win", "", "The container image containing a windows shell")
gsutilImage = flag.String("gsutil-image", "", "The container image containing gsutil")
prImage = flag.String("pr-image", "", "The container image containing our PR binary.")
imageDigestExporterImage = flag.String("imagedigest-exporter-image", "", "The container image containing our image digest exporter binary.")
Expand All @@ -65,6 +66,7 @@ func main() {
GitImage: *gitImage,
KubeconfigWriterImage: *kubeconfigWriterImage,
ShellImage: *shellImage,
ShellImageWin: *shellImageWin,
GsutilImage: *gsutilImage,
PRImage: *prImage,
ImageDigestExporterImage: *imageDigestExporterImage,
Expand Down
5 changes: 4 additions & 1 deletion config/controller.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ spec:
# The shell image must be root in order to create directories and copy files to PVCs.
# gcr.io/distroless/base:debug as of Apirl 17, 2021
# image shall not contains tag, so it will be supported on a runtime like cri-o
"-shell-image", "gcr.io/distroless/base@sha256:aa4fd987555ea10e1a4ec8765da8158b5ffdfef1e72da512c7ede509bc9966c4"
"-shell-image", "gcr.io/distroless/base@sha256:aa4fd987555ea10e1a4ec8765da8158b5ffdfef1e72da512c7ede509bc9966c4",
# for script mode to work with windows we need a powershell image
# pinning to nanoserver tag as of July 15 2021
"-shell-image-win", "mcr.microsoft.com/powershell:nanoserver@sha256:b6d5ff841b78bdf2dfed7550000fd4f3437385b8fa686ec0f010be24777654d6"
]
volumeMounts:
- name: config-logging
Expand Down
46 changes: 46 additions & 0 deletions docs/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ weight: 200
- [Defining `Steps`](#defining-steps)
- [Reserved directories](#reserved-directories)
- [Running scripts within `Steps`](#running-scripts-within-steps)
- [Windows Scripts](#windows-scripts)
- [Specifying a timeout](#specifying-a-timeout)
- [Specifying `onError` for a `step`](#specifying-onerror-for-a-step)
- [Accessing Step's `exitCode` in subsequent `Steps`](#accessing-steps-exitcode-in-subsequent-steps)
Expand Down Expand Up @@ -265,6 +266,51 @@ steps:
#!/usr/bin/env bash
/bin/my-binary
```

##### Windows scripts

Scripts in tasks that will eventually run on windows nodes need a custom shebang line, so that Tekton knows how to run the script. The format of the shebang line is:

`#!win <interpreter command> <args>`

Unlike linux, we need to specify how to interpret the script file which is generated by Tekton. The example below shows how to execute a powershell script:
Copy link

Choose a reason for hiding this comment

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

I didn't totally understand this bit on first read, since linux also uses shebang lines to specify how to interpret script files. Is it referring to the use of arguments (-File)? Or just that we have to be completely explicit otherwise tekton will assume it's for linux?

Copy link
Contributor Author

@DrWadsy DrWadsy Sep 1, 2021

Choose a reason for hiding this comment

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

We have to be explicit that this script will run under windows, and we also have to tell tekton how this script is to be run (which is what a linux shebang does for us). Windows needs to know which executable (e.g. powershell.exe, or pwsh.exe) it needs to use in order to run the script, and also specify any args (e.g. both of those .exe's require the -File argument in order to interpret commands in a file).


```yaml
steps:
- image: mcr.microsoft.com/windows/servercore:1809
script: |
#!win powershell.exe -File
echo 'Hello from PowerShell'
```

Microsoft provide `powershell` images, which contain Powershell Core (which is slightly different from powershell found in standard windows images). The example below shows how to use these images:
```yaml
steps:
- image: mcr.microsoft.com/powershell:nanoserver
script: |
#!win pwsh.exe -File
echo 'Hello from PowerShell Core'
```

As can be seen the command is different. The windows shebang can be used for any interpreter, as long as it exists in the image and can interpret commands from a file. The example below executes a Python script:
```yaml
steps:
- image: python
script: |
#!win python
print("Hello from Python!")
```
Note that other than the `#!win` shebang the example is identical to the earlier linux example.

Finally, if no interpreter is specified on the `#!win` line then the script will be treated as a windows `.cmd` file which will be excecuted. The example below shows this:
```yaml
steps:
- image: mcr.microsoft.com/powershell:lts-nanoserver-1809
script: |
#!win
echo Hello from the default cmd file
```

#### Specifying a timeout

A `Step` can specify a `timeout` field.
Expand Down
3 changes: 3 additions & 0 deletions pkg/apis/pipeline/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type Images struct {
KubeconfigWriterImage string
// ShellImage is the container image containing bash shell.
ShellImage string
// ShellImageWin is the container image containing powershell.
ShellImageWin string
// GsutilImage is the container image containing gsutil.
GsutilImage string
// PRImage is the container image that we use to implement the PR source step.
Expand All @@ -55,6 +57,7 @@ func (i Images) Validate() error {
{i.GitImage, "git"},
{i.KubeconfigWriterImage, "kubeconfig-writer"},
{i.ShellImage, "shell"},
{i.ShellImageWin, "windows-shell"},
{i.GsutilImage, "gsutil"},
{i.PRImage, "pr"},
{i.ImageDigestExporterImage, "imagedigest-exporter"},
Expand Down
2 changes: 2 additions & 0 deletions pkg/apis/pipeline/images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func TestValidate(t *testing.T) {
GitImage: "set",
KubeconfigWriterImage: "set",
ShellImage: "set",
ShellImageWin: "set",
GsutilImage: "set",
PRImage: "set",
ImageDigestExporterImage: "set",
Expand All @@ -27,6 +28,7 @@ func TestValidate(t *testing.T) {
GitImage: "", // unset!
KubeconfigWriterImage: "set",
ShellImage: "", // unset!
ShellImageWin: "set",
GsutilImage: "set",
PRImage: "", // unset!
ImageDigestExporterImage: "set",
Expand Down
7 changes: 7 additions & 0 deletions pkg/apis/pipeline/v1beta1/task_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,13 @@ func validateStep(ctx context.Context, s Step, names sets.String) (errs *apis.Fi
})
}
}

if s.Script != "" {
cleaned := strings.TrimSpace(s.Script)
if strings.HasPrefix(cleaned, "#!win") {
errs = errs.Also(ValidateEnabledAPIFields(ctx, "windows script support", config.AlphaAPIFields).ViaField("script"))
}
}
return errs
}

Expand Down
13 changes: 13 additions & 0 deletions pkg/apis/pipeline/v1beta1/task_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,19 @@ func TestIncompatibleAPIVersions(t *testing.T) {
},
}},
},
}, {
name: "windows script support requires alpha",
requiredVersion: "alpha",
spec: v1beta1.TaskSpec{
Steps: []v1beta1.Step{{
Container: corev1.Container{
Image: "my-image",
},
Script: `
#!win powershell -File
script-1`,
}},
},
}}
versions := []string{"alpha", "stable"}
for _, tt := range tests {
Expand Down
4 changes: 2 additions & 2 deletions pkg/pod/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,9 @@ func (b *Builder) Build(ctx context.Context, taskRun *v1beta1.TaskRun, taskSpec
// Convert any steps with Script to command+args.
// If any are found, append an init container to initialize scripts.
if alphaAPIEnabled {
scriptsInit, stepContainers, sidecarContainers = convertScripts(b.Images.ShellImage, steps, taskSpec.Sidecars, taskRun.Spec.Debug)
scriptsInit, stepContainers, sidecarContainers = convertScripts(b.Images.ShellImage, b.Images.ShellImageWin, steps, taskSpec.Sidecars, taskRun.Spec.Debug)
Copy link
Member

Choose a reason for hiding this comment

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

Should windows support go into the alpha feature gate, alongside debug support? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point.
I changed this call because I changed the signature of the convertScripts method, but we could pass nil and check for that before we do any windows checks?

Copy link

Choose a reason for hiding this comment

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

Passing nil or an empty string for the windows shell image when alphaAPIEnabled is false would work ok I think. An alternative would be to write an entirely windows-focused convertScripts implementation and only invoke it if alphaAPIEnabled is true and requiresWindows would be true. Might involve a bit more duplication or end up being more lines but it might reduce the number of windows-specific branches? Could also push a change like that back to a refactor pr in future as well though.

} else {
scriptsInit, stepContainers, sidecarContainers = convertScripts(b.Images.ShellImage, steps, taskSpec.Sidecars, nil)
scriptsInit, stepContainers, sidecarContainers = convertScripts(b.Images.ShellImage, "", steps, taskSpec.Sidecars, nil)
}
if scriptsInit != nil {
initContainers = append(initContainers, *scriptsInit)
Expand Down
106 changes: 89 additions & 17 deletions pkg/pod/script.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,20 +70,28 @@ var (
)

// convertScripts converts any steps and sidecars that specify a Script field into a normal Container.
//
// It does this by prepending a container that writes specified Script bodies
// to executable files in a shared volumeMount, then produces Containers that
// simply run those executable files.
func convertScripts(shellImage string, steps []v1beta1.Step, sidecars []v1beta1.Sidecar, debugConfig *v1beta1.TaskRunDebug) (*corev1.Container, []corev1.Container, []corev1.Container) {
func convertScripts(shellImageLinux string, shellImageWin string, steps []v1beta1.Step, sidecars []v1beta1.Sidecar, debugConfig *v1beta1.TaskRunDebug) (*corev1.Container, []corev1.Container, []corev1.Container) {
placeScripts := false
// Place scripts is an init container used for creating scripts in the
Copy link

Choose a reason for hiding this comment

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

This comment goes with the corev1.Container struct declared on line 94 I think. Suggest moving it down or removing it.

// /tekton/scripts directory which would be later used by the step containers
// as a Command
requiresWindows := checkWindowsRequirement(steps, sidecars)

shellImage := shellImageLinux
shellCommand := "sh"
shellArg := "-c"
// Set windows variants for Image, Command and Args
if requiresWindows {
shellImage = shellImageWin
shellCommand = "pwsh"
shellArg = "-Command"
}

placeScriptsInit := corev1.Container{
Name: "place-scripts",
Image: shellImage,
Command: []string{"sh"},
Args: []string{"-c", ""},
Command: []string{shellCommand},
Args: []string{shellArg, ""},
VolumeMounts: []corev1.VolumeMount{writeScriptsVolumeMount, toolsMount},
}

Expand Down Expand Up @@ -131,6 +139,7 @@ func convertListOfSteps(steps []v1beta1.Step, initContainer *corev1.Container, p
// The shebang must be the first non-empty line.
cleaned := strings.TrimSpace(s.Script)
hasShebang := strings.HasPrefix(cleaned, "#!")
requiresWindows := strings.HasPrefix(cleaned, "#!win")

script := s.Script
if !hasShebang {
Expand All @@ -141,26 +150,40 @@ func convertListOfSteps(steps []v1beta1.Step, initContainer *corev1.Container, p
// non-nil init container.
*placeScripts = true

script = encodeScript(script)

// Append to the place-scripts script to place the
// script file in a known location in the scripts volume.
scriptFile := filepath.Join(scriptsDir, names.SimpleNameGenerator.RestrictLengthWithRandomSuffix(fmt.Sprintf("%s-%d", namePrefix, i)))
heredoc := "_EOF_" // underscores because base64 doesnt include them in its alphabet
initContainer.Args[1] += fmt.Sprintf(`scriptfile="%s"
if requiresWindows {
command, args, script, scriptFile := extractWindowsScriptComponents(script, scriptFile)
initContainer.Args[1] += fmt.Sprintf(`@"
%s
"@ | Out-File -FilePath %s
`, script, scriptFile)

steps[i].Command = command
// Append existing args field to end of derived args
args = append(args, steps[i].Args...)
steps[i].Args = args
} else {
// Only encode the script for linux scripts
// The decode-script subcommand of the entrypoint does not work under windows
script = encodeScript(script)
heredoc := "_EOF_" // underscores because base64 doesnt include them in its alphabet
initContainer.Args[1] += fmt.Sprintf(`scriptfile="%s"
touch ${scriptfile} && chmod +x ${scriptfile}
cat > ${scriptfile} << '%s'
%s
%s
/tekton/tools/entrypoint decode-script "${scriptfile}"
`, scriptFile, heredoc, script, heredoc)

// Set the command to execute the correct script in the mounted
// volume.
// A previous merge with stepTemplate may have populated
// Command and Args, even though this is not normally valid, so
// we'll clear out the Args and overwrite Command.
steps[i].Command = []string{scriptFile}
// Set the command to execute the correct script in the mounted
// volume.
// A previous merge with stepTemplate may have populated
// Command and Args, even though this is not normally valid, so
// we'll clear out the Args and overwrite Command.
steps[i].Command = []string{scriptFile}
}
steps[i].VolumeMounts = append(steps[i].VolumeMounts, scriptsVolumeMount)

// Add debug mounts if breakpoints are present
Expand Down Expand Up @@ -207,3 +230,52 @@ cat > ${scriptfile} << '%s'
func encodeScript(script string) string {
return base64.StdEncoding.EncodeToString([]byte(script))
}

func checkWindowsRequirement(steps []v1beta1.Step, sidecars []v1beta1.Sidecar) bool {
// Detect windows shebangs
for _, step := range steps {
cleaned := strings.TrimSpace(step.Script)
if strings.HasPrefix(cleaned, "#!win") {
return true
}
}
// If no step needs windows, then check sidecars to be sure
for _, sidecar := range sidecars {
cleaned := strings.TrimSpace(sidecar.Script)
if strings.HasPrefix(cleaned, "#!win") {
return true
}
}
return false
}

func extractWindowsScriptComponents(script string, fileName string) ([]string, []string, string, string) {
// Set the command to execute the correct script in the mounted volume.
shebangLine := strings.Split(script, "\n")[0]
splitLine := strings.Split(shebangLine, " ")
var command, args []string
if len(splitLine) > 1 {
strippedCommand := splitLine[1:]
command = strippedCommand[0:1]
// Handle legacy powershell limitation
if strings.HasPrefix(command[0], "powershell") {
fileName += ".ps1"
}
if len(strippedCommand) > 1 {
args = strippedCommand[1:]
args = append(args, fileName)
} else {
args = []string{fileName}
}
} else {
// If no interpreter is specified then strip the shebang and
// create a .cmd file
fileName += ".cmd"
commandLines := strings.Split(script, "\n")[1:]
script = strings.Join(commandLines, "\n")
command = []string{fileName}
args = []string{}
}

return command, args, script, fileName
}
Loading