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

devcontainer commands #197

Merged
merged 5 commits into from
Jan 22, 2023
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
6 changes: 0 additions & 6 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,6 @@ on:
branches: [ main ]
push:
branches: [ main ]
paths:
- .github/workflows/verify.yml
- src/**
- pom.xml
- build/copr/ilo.spec
- HomebrewFormula/ilo.rb
jobs:
verify:
name: Build on ${{ matrix.os }}
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<parent>
<groupId>wtf.metio.maven.parents</groupId>
<artifactId>maven-parents-java-stable</artifactId>
<version>2022.12.30</version>
<version>2023.1.20</version>
</parent>

<!-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -->
Expand Down
50 changes: 50 additions & 0 deletions src/main/java/wtf/metio/ilo/devcontainer/DevcontainerCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@
package wtf.metio.ilo.devcontainer;

import picocli.CommandLine;
import wtf.metio.devcontainer.Command;
import wtf.metio.devcontainer.Devcontainer;
import wtf.metio.ilo.cli.Executables;
import wtf.metio.ilo.compose.ComposeCommand;
import wtf.metio.ilo.errors.DevcontainerJsonMissingException;
import wtf.metio.ilo.errors.RuntimeIOException;
import wtf.metio.ilo.os.ShellTokenizer;
import wtf.metio.ilo.shell.ShellCommand;
import wtf.metio.ilo.utils.Streams;
import wtf.metio.ilo.utils.Strings;
Expand All @@ -20,6 +24,10 @@
import java.nio.file.Paths;
import java.util.Objects;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.function.Supplier;

import static wtf.metio.ilo.devcontainer.DevcontainerOptionsMapper.composeOptions;
import static wtf.metio.ilo.devcontainer.DevcontainerOptionsMapper.shellOptions;
Expand Down Expand Up @@ -47,6 +55,13 @@ public Integer call() throws IOException {
.orElseThrow(DevcontainerJsonMissingException::new);
final var devcontainer = Devcontainer.parse(json);

if (options.executeInitializeCommand) {
final var exitCode = runCommand(devcontainer.initializeCommand(), options.debug);
if (CommandLine.ExitCode.OK != exitCode) {
return exitCode;
}
}

if (Objects.nonNull(devcontainer.dockerComposeFile()) && !devcontainer.dockerComposeFile().isEmpty()) {
final var command = new ComposeCommand();
command.options = composeOptions(options, devcontainer, json);
Expand All @@ -60,4 +75,39 @@ public Integer call() throws IOException {
return CommandLine.ExitCode.USAGE;
}

// visible for testing
int runCommand(final Command command, final boolean debug) {
try {
if (Strings.isNotBlank(command.string())) {
return Executables.runAndWaitForExit(ShellTokenizer.tokenize(command.string()), debug);
}
if (Objects.nonNull(command.array()) && !command.array().isEmpty()) {
return Executables.runAndWaitForExit(command.array(), debug);
}
if (Objects.nonNull(command.object()) && !command.object().isEmpty()) {
final var futures = command.object().values().stream()
.map(cmd -> (Supplier<Integer>) () -> runCommand(cmd, debug))
.map(CompletableFuture::supplyAsync)
.toList();

return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new))
.thenApply(ignore -> {
for (final var future : futures) {
final var exitCode = future.join();
if (CommandLine.ExitCode.OK != exitCode) {
return exitCode;
}
}
return CommandLine.ExitCode.OK;
})
.join();
}

return CommandLine.ExitCode.OK;
} catch (final RuntimeIOException | CompletionException exception) {
System.err.println(exception.getCause().getMessage());
return CommandLine.ExitCode.USAGE;
}
}

}
54 changes: 54 additions & 0 deletions src/main/java/wtf/metio/ilo/devcontainer/DevcontainerOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,60 @@ public final class DevcontainerOptions implements Options {
)
public boolean removeImage;

@CommandLine.Option(
names = {"--execute-initialize-command"},
description = "Execute the 'initializeCommand' before creating containers.",
defaultValue = "true",
fallbackValue = "true",
negatable = true
)
public boolean executeInitializeCommand;

@CommandLine.Option(
names = {"--execute-on-create-command"},
description = "Execute the 'onCreateCommand' after a container was started.",
defaultValue = "true",
fallbackValue = "true",
negatable = true
)
public boolean executeOnCreateCommand;

@CommandLine.Option(
names = {"--execute-update-content-command"},
description = "Execute the 'updateContentCommand' after new content is available during the creation process.",
defaultValue = "true",
fallbackValue = "true",
negatable = true
)
public boolean executeUpdateContentCommand;

@CommandLine.Option(
names = {"--execute-post-create-command"},
description = "Execute the 'postCreateCommand' after a container was created.",
defaultValue = "true",
fallbackValue = "true",
negatable = true
)
public boolean executePostCreateCommand;

@CommandLine.Option(
names = {"--execute-post-start-command"},
description = "Execute the 'postStartCommand' after a container was started.",
defaultValue = "true",
fallbackValue = "true",
negatable = true
)
public boolean executePostStartCommand;

@CommandLine.Option(
names = {"--execute-post-attach-command"},
description = "Execute the 'postAttachCommand' after attaching to a container.",
defaultValue = "true",
fallbackValue = "true",
negatable = true
)
public boolean executePostAttachCommand;

@CommandLine.Parameters(
index = "0..*",
description = "List of possible locations for a devcontainer.json file. First found will be used.",
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/wtf/metio/ilo/errors/RuntimeIOException.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
public final class RuntimeIOException extends BusinessException {

public RuntimeIOException(final IOException exception) {
super(104, exception, "Random I/O error occurred.");
super(104, exception, "I/O error occurred.");
}

}
160 changes: 160 additions & 0 deletions src/main/java/wtf/metio/ilo/os/ShellTokenizer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
* This file is part of ilo. It is subject to the license terms in the LICENSE file found in the top-level
* directory of this distribution and at https://creativecommons.org/publicdomain/zero/1.0/. No part of ilo,
* including this file, may be copied, modified, propagated, or distributed except according to the terms contained
* in the LICENSE file.
*/

package wtf.metio.ilo.os;

import java.util.ArrayList;
import java.util.List;

public final class ShellTokenizer {

private static final int NO_TOKEN_STATE = 0;
private static final int NORMAL_TOKEN_STATE = 1;
private static final int SINGLE_QUOTE_STATE = 2;
private static final int DOUBLE_QUOTE_STATE = 3;

private ShellTokenizer() {
// utility class
}

/**
* Tokenizes the given String
*
* @param arguments A String containing one or more command-line style arguments to be tokenized.
* @return A list of parsed and properly escaped arguments.
*/
public static List<String> tokenize(final String arguments) {
return tokenize(arguments, false);
}

/**
* Tokenizes the given String into String tokens.
*
* @param arguments A String containing one or more command-line style arguments to be tokenized.
* @param stringify Whether to include escape special characters
* @return A list of parsed and properly escaped arguments.
*/
public static List<String> tokenize(final String arguments, final boolean stringify) {
final var tokens = new ArrayList<String>();
var builder = new StringBuilder();
var escaped = false;
var state = NO_TOKEN_STATE;

for (var index = 0; index < arguments.length(); index++) {
final var character = arguments.charAt(index);
if (escaped) {
escaped = false;
builder.append(character);
} else {
switch (state) {
case SINGLE_QUOTE_STATE -> {
if ('\'' == character) {
// Seen the close quote; continue this arg until whitespace is seen
state = NORMAL_TOKEN_STATE;
} else {
builder.append(character);
}
}
case DOUBLE_QUOTE_STATE -> {
if ('"' == character) {
// Seen the close quote; continue this arg until whitespace is seen
state = NORMAL_TOKEN_STATE;
} else if ('\\' == character) {
// Look ahead, and only escape quotes or backslashes
index++;
final var next = arguments.charAt(index);
if ('"' == next || '\\' == next) {
builder.append(next);
} else {
builder.append(character);
builder.append(next);
}
} else {
builder.append(character);
}
}
case NO_TOKEN_STATE, NORMAL_TOKEN_STATE -> {
switch (character) {
case '\\' -> {
escaped = true;
state = NORMAL_TOKEN_STATE;
}
case '\'' -> state = SINGLE_QUOTE_STATE;
case '"' -> state = DOUBLE_QUOTE_STATE;
default -> {
if (!Character.isWhitespace(character)) {
builder.append(character);
state = NORMAL_TOKEN_STATE;
} else if (NORMAL_TOKEN_STATE == state) {
// Whitespace ends the token; start a new one
tokens.add(builder.toString());
builder = new StringBuilder();
state = NO_TOKEN_STATE;
}
}
}
}
default -> throw new IllegalStateException("ShellTokenizer state " + state + " is invalid!");
}
}
}

// If we're still escaped, put in the backslash
if (escaped) {
builder.append('\\');
tokens.add(builder.toString());
}
// Close the last argument if we haven't yet
else if (NO_TOKEN_STATE != state) {
tokens.add(builder.toString());
}
// Format each argument if we've been told to stringify them
if (stringify) {
tokens.replaceAll(original -> "\"" + escapeQuotesAndBackslashes(original) + "\"");
}
return tokens;
}

/**
* Inserts backslashes before any occurrences of a backslash or
* quote in the given string. Also converts any special characters
* appropriately.
*/
private static String escapeQuotesAndBackslashes(final String original) {
final var builder = new StringBuilder(original);

// Walk backwards, looking for quotes or backslashes.
// If we see any, insert an extra backslash into the buffer at
// the same index. (By walking backwards, the index into the buffer
// will remain correct as we change the buffer.)
for (var index = original.length() - 1; 0 <= index; index--) {
final var character = original.charAt(index);
if ('\\' == character || '"' == character) {
builder.insert(index, '\\');
}
// Replace any special characters with escaped versions
else if ('\n' == character) {
builder.deleteCharAt(index);
builder.insert(index, "\\n");
} else if ('\t' == character) {
builder.deleteCharAt(index);
builder.insert(index, "\\t");
} else if ('\r' == character) {
builder.deleteCharAt(index);
builder.insert(index, "\\r");
} else if ('\b' == character) {
builder.deleteCharAt(index);
builder.insert(index, "\\b");
} else if ('\f' == character) {
builder.deleteCharAt(index);
builder.insert(index, "\\f");
}
}
return builder.toString();
}

}
Loading