diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..11027ba --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "version": "2.17.0", + "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c", + "integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c" + }, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.3.4", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..81b7867 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "Corteca CLI", + "image": "mcr.microsoft.com/devcontainers/go:1.24", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "version": "latest", + "moby": false + }, + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers/features/go:1": { + "version": "none", + "golangciLintVersion": "latest" + } + }, + "postCreateCommand": "sudo apt-get update && sudo apt-get install -y msitools ", + "customizations": { + "vscode": { + "extensions": [ + "streetsidesoftware.code-spell-checker", + "golang.Go", + "davidanson.vscode-markdownlint", + "github.vscode-github-actions", + "ms-azuretools.vscode-containers", + "ms-vscode.makefile-tools" + ] + } + }, + "remoteEnv": { + "PODMAN_USERS": "keep-id", + "IN_DEVCONTAINER": "1" + }, + "containerUser": "vscode" +} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 600c3cd..519b92c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,14 +11,68 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # required for git describe --tags used in VERSION - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod - name: Build run: make + + test: + name: Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 # required for git describe --tags used in VERSION + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Run tests + run: make test + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./coverage.out + flags: unittests + token: ${{ secrets.CODECOV_TOKEN }} + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Go lint + uses: golangci/golangci-lint-action@v7 + with: + version: latest + + - name: YAML lint + run: | + pip install --quiet yamllint + yamllint -c .yamllint.yaml . + + - name: Markdown lint + uses: DavidAnson/markdownlint-cli2-action@v18 + with: + config: .markdownlint.yaml + globs: '**/*.md' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6b9145f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + name: Build and publish + runs-on: ubuntu-latest + permissions: + contents: write # create GitHub Release and upload assets + packages: write # push to GHCR + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 # required for git describe --tags + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Install nfpm + run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.40.0 + + - name: Get version + id: version + run: echo "version=$(git describe --tags)" >> "$GITHUB_OUTPUT" + + - name: Build packages + run: | + make deb DESTARCH=amd64 + make deb DESTARCH=arm64 + make rpm DESTARCH=amd64 + make rpm DESTARCH=arm64 + make osx DESTARCH=amd64 + make osx DESTARCH=arm64 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/packages/* + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push container image + uses: docker/build-push-action@v6 + with: + context: . + file: corteca-cli.Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + VERSION=${{ steps.version.outputs.version }} + tags: | + ghcr.io/${{ github.repository }}:latest + ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 3d35284..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Tests - -on: - push: - pull_request: - -jobs: - test: - name: Tests - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 # required for git describe --tags used in VERSION - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Run tests - run: make test - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - files: ./coverage.out - flags: unittests - token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 7df794d..91ff407 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ /dist nfpm.yaml coverage.out + +.DS_Store +.claude \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..1e98b6d --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,12 @@ +version: "2" + +run: + timeout: 5m + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..553fde5 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,3 @@ +default: true +MD013: false # line length — not enforced +MD033: false # inline HTML — used in README (logo div) diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 0000000..794a3dc --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,7 @@ +extends: default + +rules: + line-length: + max: 120 + truthy: + allowed-values: ['true', 'false'] diff --git a/README.md b/README.md index 45db6b7..27ca2a0 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ +
Nokia Logo
+ # Corteca Developer Toolkit [![Build](https://github.com/nokia/corteca-cli/actions/workflows/build.yml/badge.svg)](https://github.com/nokia/corteca-cli/actions/workflows/build.yml) -[![Tests](https://github.com/nokia/corteca-cli/actions/workflows/test.yml/badge.svg)](https://github.com/nokia/corteca-cli/actions/workflows/test.yml) [![Coverage](https://codecov.io/gh/nokia/corteca-cli/branch/main/graph/badge.svg)](https://codecov.io/gh/nokia/corteca-cli) Corteca Developer Toolkit is a command-line tool for building, packaging, and @@ -69,95 +70,24 @@ supported: Devices and the sequences to run on them are configured in `corteca.yaml` and can be targeted by name when running `corteca exec`. -
- -## Prerequisites - -| Requirement | Version | Notes | -| ----------- | -------- | ---------------------------------------------- | -| Go | ≥ 1.21 | Required to build from source | -| Docker | ≥ 23.0 | Required to build application container images | -| Docker BuildKit | ≥ 0.11 | Required for `docker build --output` | -| make | any | Used to drive the build and install targets | - ## Build -Clone the repository and run: - -```bash -$ make -``` - -The compiled binary is placed in the `dist/` directory. - -### Build using Docker - -If you do not have a local Go toolchain, you can build entirely inside Docker -(BuildKit is required): - -```bash -$ docker build --output ./dist . -``` - -#### Installing Docker BuildKit - -On Docker Engine < 23.0, BuildKit must be enabled manually. On Ubuntu 22.04: - -```bash -$ sudo apt-get install docker-buildx-plugin -``` - -Then either prefix each `docker build` invocation with `DOCKER_BUILDKIT=1`, or -follow the [official instructions](https://docs.docker.com/build/buildkit/#getting-started) -to enable it globally. - -> For the full build guide see [doc/BUILD.md](doc/BUILD.md). +For build instructions see [doc/BUILD.md](doc/BUILD.md). ## Install -### Install manually from source - -You can run the below command to build the application and install the binary to -the `/usr/bin` folder and the default configuration files to the `/etc/corteca` -folder: - -```bash -$ sudo make install -``` - -To remove a previous manual installation - -```bash -$ sudo make uninstall -``` - -You can customize the destination folder by overriding the `$DESTDIR` -environment variable: - -```bash -# no sudo required; will be installed to ~/.local/share/usr/bin -$ DESTDIR=~/.local/share make install -``` - -### Install with package manager - -If you are using debian/ubuntu or redhat-based distributions, you can create a relevant package and let your package manager handle installation. E.g. for ubuntu: - -```bash -$ make deb -$ make rpm -``` +For installation instructions see [doc/INSTALL.md](doc/INSTALL.md). ## Getting Started The fastest way to get up and running is to create a project, build it, and publish it to a local registry in three commands: -```bash -$ corteca create my-app # scaffold a new application project -$ cd my-app -$ corteca build aarch64 # build an OCI image for aarch64 -$ corteca publish localRegistry # push it to a local OCI registry +```shell +corteca create my-app # scaffold a new application project +cd my-app +corteca build aarch64 # build an OCI image for aarch64 +corteca publish localRegistry # push it to a local OCI registry ``` For a step-by-step walkthrough — including how to configure a device target and @@ -180,7 +110,7 @@ directory, so `corteca` commands work from any subdirectory of a project. The `corteca config` command can be used to inspect or modify any value without editing YAML by hand: -```bash +```shell corteca config get publish # show all publish targets corteca config set app.version 1.1 # update a value ``` @@ -198,14 +128,36 @@ reference](doc/Configuration.md). | [`corteca publish`](doc/reference/corteca_publish.md) | Upload or serve the build artifact via a configured publish target | | [`corteca exec`](doc/reference/corteca_exec.md) | Run a named deployment sequence on a configured device | | [`corteca config`](doc/reference/corteca_config.md) | Inspect or modify configuration values | +| [`corteca config add`](doc/reference/corteca_config_add.md) | Add a configuration value | +| [`corteca config get`](doc/reference/corteca_config_get.md) | Get a configuration value | +| [`corteca config set`](doc/reference/corteca_config_set.md) | Set a configuration value | | [`corteca regen`](doc/reference/corteca_regen.md) | Regenerate template-derived project files | -For a broader overview of all commands, flags, and usage patterns see -[doc/USAGE.md](doc/USAGE.md). - Every command also accepts `--help` for inline usage information: -```bash +```shell corteca --help corteca build --help ``` + +## Application Templates + +`corteca` scans for available templates in a `templates/` folder that resides in the global config folder (defaults to `$HOME/.config/corteca`). You can override the global config folder using the `--configRoot` flag. + +Each subfolder containing a `.template-info.yaml` file will be treated as a template and will be rendered with all configuration variables available for rendering. For more information, see the [Configuration](#configuration) section above. + +Here is an overview of the content of `.template-info.yaml`: + +```yaml +# Name of the template +name: "foobar" +description: "Short template description" +dependencies: + compile: [ foo ] # Dependencies needed during compile + runtime: [ bar ] # Dependencies needed during runtime +# Dynamic files that need regeneration during the project's lifecycle +regenFiles: + .corteca/foo.template: foo # Mapping template filepath to regenerated filepath +# A collection of custom options available for rendering +options: +``` diff --git a/cmd/build.go b/cmd/build.go index 515ae2f..420c11f 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -96,7 +96,7 @@ func doBuildApp(selectedArchitecture string) { if rootfsPath == "" { assertOperation(fmt.Sprintf("building '%s'", selectedArchitecture), builder.BuildRootFS(projectRoot, rootfsBuildPath, settings, config.App, config.Build)) } else { - fsutil.ExtractTarball(rootfsPath, rootfsBuildPath) + assertOperation(fmt.Sprintf("extracting rootfs from '%s'", rootfsPath), fsutil.ExtractTarball(rootfsPath, rootfsBuildPath)) } // STEP 2: validate rootfs diff --git a/cmd/config.go b/cmd/config.go index 83103f7..e165072 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -113,7 +113,7 @@ func doGetConfigValue(key string) { assertOperation("retrieving config value", err) enc := yaml.NewEncoder(os.Stdout) enc.SetIndent(configuration.YamlIndentation) - enc.Encode(field) + assertOperation("encoding config value", enc.Encode(field)) } func doSetConfigValue(key, value string, append bool) { @@ -144,7 +144,7 @@ func doEvalConfigValue(expr string) { field := configuration.T(expr) enc := yaml.NewEncoder(os.Stdout) enc.SetIndent(configuration.YamlIndentation) - enc.Encode(field.String()) + assertOperation("encoding config value", enc.Encode(field.String())) } func validConfigArgsFunc(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/cmd/doc.go b/cmd/doc.go index 36107a2..2b1e001 100644 --- a/cmd/doc.go +++ b/cmd/doc.go @@ -48,7 +48,7 @@ func doGenerateDocumentation(cmdNames []string) { continue } cmdFile := createMdFile(cmd) - defer cmdFile.Close() + defer func() { _ = cmdFile.Close() }() err = generateMarkdown(cmd, cmdFile, false) assertOperation("generating documentation for corteca commands", err) } @@ -67,7 +67,7 @@ func doGenerateDocumentation(cmdNames []string) { index-- cmdFile := createMdFile(cmd) - defer cmdFile.Close() + defer func() { _ = cmdFile.Close() }() err = generateMarkdown(cmd, cmdFile, false) assertOperation("generating documentation for specific corteca command(s)", err) } @@ -82,30 +82,38 @@ func doGenerateDocumentation(cmdNames []string) { if len(notMatchedCmds) == 0 { fmt.Println("Cortecacli's commands documentation has generated successfuly!") } else { - fmt.Fprintf(os.Stdout, "The following commands are not part of cortecacli: %s\n", notMatchedCmds) + _, _ = fmt.Fprintf(os.Stdout, "The following commands are not part of cortecacli: %s\n", notMatchedCmds) } } else { - fmt.Fprintf(os.Stdout, "No command documentation was generated\nThe following commands are not part of cortecacli: %s\n", notMatchedCmds) + _, _ = fmt.Fprintf(os.Stdout, "No command documentation was generated\nThe following commands are not part of cortecacli: %s\n", notMatchedCmds) } } func generateMarkdown(cmd *cobra.Command, cmdFile *os.File, isSubcmd bool) error { // Write command usage if isSubcmd { - _, writeToFileErr = cmdFile.WriteString(fmt.Sprintf("### Corteca %s %s\n\n%s\n\n", cmd.Parent().Name(), cmd.Name(), cmd.Long)) + _, writeToFileErr = fmt.Fprintf(cmdFile, "### Corteca %s %s\n\n%s\n\n", cmd.Parent().Name(), cmd.Name(), cmd.Long) } else { - _, writeToFileErr = cmdFile.WriteString(fmt.Sprintf("## Corteca %s\n\n%s\n\n", cmd.Name(), cmd.Long)) + _, writeToFileErr = fmt.Fprintf(cmdFile, "## Corteca %s\n\n%s\n\n", cmd.Name(), cmd.Long) } if writeToFileErr != nil { return writeToFileErr } - cmdFile.WriteString(fmt.Sprintf("### Usage:\n\n```\n%s\n```\n\n", cmd.UseLine())) - cmdFile.WriteString(fmt.Sprintf("### Flags:\n\n```\n%s%s\n```\n\n", cmd.InheritedFlags().FlagUsages(), cmd.LocalFlags().FlagUsages())) - cmdFile.WriteString(fmt.Sprintf("### Example:\n\n```\n%s\n```\n\n", cmd.Example)) + if _, err := fmt.Fprintf(cmdFile, "### Usage:\n\n```\n%s\n```\n\n", cmd.UseLine()); err != nil { + return err + } + if _, err := fmt.Fprintf(cmdFile, "### Flags:\n\n```\n%s%s\n```\n\n", cmd.InheritedFlags().FlagUsages(), cmd.LocalFlags().FlagUsages()); err != nil { + return err + } + if _, err := fmt.Fprintf(cmdFile, "### Example:\n\n```\n%s\n```\n\n", cmd.Example); err != nil { + return err + } // Append subcommands to parent command's documentation, if any if len(cmd.Commands()) > 0 { - cmdFile.WriteString("## Subcommands:\n\n") + if _, err := fmt.Fprintf(cmdFile, "## Subcommands:\n\n"); err != nil { + return err + } for _, subCmd := range cmd.Commands() { // Recursive call for nested subcommands err := generateMarkdown(subCmd, cmdFile, true) @@ -113,7 +121,9 @@ func generateMarkdown(cmd *cobra.Command, cmdFile *os.File, isSubcmd bool) error } } - cmdFile.WriteString("\n") + if _, err := fmt.Fprintf(cmdFile, "\n"); err != nil { + return err + } return nil } diff --git a/cmd/exec.go b/cmd/exec.go index 1c0bf36..d54d2aa 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -33,7 +33,7 @@ var logFile string var publishTargetName string func init() { - execCmd.RegisterFlagCompletionFunc("artifact", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + _ = execCmd.RegisterFlagCompletionFunc("artifact", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"tar.gz, tar"}, cobra.ShellCompDirectiveFilterFileExt }) rootCmd.AddCommand(execCmd) diff --git a/cmd/publish.go b/cmd/publish.go index ecc0a94..3f8f44e 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -21,11 +21,6 @@ import ( "github.com/spf13/cobra" ) -const ( - ociSuffix = "oci" - rootfsSuffix = "rootfs" - artifactNotFoundMessage = "No build artifact found for [%s,%s]" -) var publishCmd = &cobra.Command{ Use: "publish TARGET", @@ -49,7 +44,7 @@ type RegistryConfig struct { func init() { publishCmd.PersistentFlags().BoolVar(&skipLocalConfig, "global", false, "Affect global config & ignore any project-local configuration") publishCmd.PersistentFlags().StringVarP(&artifact, "artifact", "a", "", "Specify the path to a an artifact to publish") - publishCmd.RegisterFlagCompletionFunc("artifact", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + _ = publishCmd.RegisterFlagCompletionFunc("artifact", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"tar.gz"}, cobra.ShellCompDirectiveFilterFileExt }) rootCmd.AddCommand(publishCmd) @@ -65,19 +60,19 @@ func doPublishApp(targetName string, wait bool) { switch target.Method { case "listen": serverConfig := configuration.HttpServerEndpoint{} - target.Decode(&serverConfig) + assertOperation("decoding publish config", target.Decode(&serverConfig)) handleListen(serverConfig, wait) case "put": clientConfig := configuration.HttpClientEndpoint{} - target.Decode(&clientConfig) + assertOperation("decoding publish config", target.Decode(&clientConfig)) handlePut(clientConfig, artifact) case "push": clientConfig := configuration.HttpClientEndpoint{} - target.Decode(&clientConfig) + assertOperation("decoding publish config", target.Decode(&clientConfig)) handlePush(clientConfig, artifact) case "registry-v2": registryConfig := RegistryConfig{} - target.Decode(®istryConfig) + assertOperation("decoding publish config", target.Decode(®istryConfig)) handleRegistry(registryConfig, wait) default: failOperation(fmt.Sprintf("unknown publish method '%v'", target.Method)) @@ -93,7 +88,7 @@ func handleListen(target configuration.HttpServerEndpoint, wait bool) { assertOperation("starting server", err) if wait { waitForInterruptSignal() - srv.Shutdown(context.Background()) + assertOperation("shutting down server", srv.Shutdown(context.Background())) } else { fmt.Printf("Serving %v on %v\n", serverRoot, u.String()) } diff --git a/cmd/root.go b/cmd/root.go index b8acbd3..0717185 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -61,20 +61,22 @@ func init() { rootCmd.SetVersionTemplate("{{.Short}} v{{.Version}}\n") rootCmd.CompletionOptions.HiddenDefaultCmd = true rootCmd.PersistentFlags().StringArrayVarP(&configOverrides, "config", "c", []string{}, "Override a configuration value in the form of a 'key=value' pair") - rootCmd.RegisterFlagCompletionFunc("config", validConfigArgsFunc) + _ = rootCmd.RegisterFlagCompletionFunc("config", validConfigArgsFunc) rootCmd.PersistentFlags().StringVarP(&systemConfigRoot, "configRoot", "r", systemConfigRoot, "Override configuration root folder") - rootCmd.RegisterFlagCompletionFunc("configRoot", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + _ = rootCmd.RegisterFlagCompletionFunc("configRoot", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return nil, cobra.ShellCompDirectiveFilterDirs }) rootCmd.PersistentFlags().StringVarP(&projectRoot, "projectRoot", "C", projectRoot, "Specify project root folder") - rootCmd.RegisterFlagCompletionFunc("projectRoot", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + _ = rootCmd.RegisterFlagCompletionFunc("projectRoot", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return nil, cobra.ShellCompDirectiveFilterDirs }) rootCmd.PersistentFlags().BoolVar(&tui.DisableColoredOutput, "no-color", false, "Disables colored stdout|stderr output") } func Execute() { - rootCmd.Execute() + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } } func readLocalConfiguration() { @@ -206,16 +208,6 @@ func requireProjectContext() { configuration.GetCmdContext().Build = &config.Build } -func getAppNameFromArtifact(artifactPath string) string { - artifactName := filepath.Base(artifactPath) - splitedArtifactName := strings.SplitN(artifactName, "-", 2) - // If artifact name doesn't contain hyphens we consider the form "appName.tar.gz", else we consider App.Name the string up to the first hyphen. - if len(splitedArtifactName) == 1 { - return strings.TrimSuffix(splitedArtifactName[0], ".tar.gz") - } else { - return splitedArtifactName[0] - } -} func requireBuildArtifact() { if artifact != "" { diff --git a/corteca-cli.Dockerfile b/corteca-cli.Dockerfile new file mode 100644 index 0000000..5853f33 --- /dev/null +++ b/corteca-cli.Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1 +# +# Runtime container image for corteca CLI. +# Includes the corteca binary and Docker CLI (required for `docker buildx build`). +# +# Requires pre-built binaries in dist/bin/ (produced by `make`). +# See doc/BUILD.md for local build and test instructions. + +ARG DOCKER_VERSION=27 + +FROM docker:${DOCKER_VERSION}-cli + +LABEL org.opencontainers.image.title="corteca-cli" \ + org.opencontainers.image.description="Corteca Developer Toolkit — CLI for building and deploying Corteca applications" \ + org.opencontainers.image.source="https://github.com/nokia/corteca-cli" \ + org.opencontainers.image.licenses="BSD-3-Clause" + +RUN apk add --no-cache ca-certificates + +ARG TARGETARCH +ARG VERSION +COPY dist/bin/corteca-linux-${TARGETARCH}-${VERSION} /usr/local/bin/corteca +COPY data/ /etc/corteca/ + +ENTRYPOINT ["corteca"] diff --git a/data/templates/c/.template-info.yaml b/data/templates/c/.template-info.yaml index f91aeb6..7c62732 100644 --- a/data/templates/c/.template-info.yaml +++ b/data/templates/c/.template-info.yaml @@ -1,6 +1,6 @@ name: "c" description: "An application template for the C language." dependencies: - compile: [ gcc, make, g++ ] - runtime: [ json-c ] + compile: [gcc, make, g++] + runtime: [json-c] base: _base diff --git a/data/templates/cpp/.template-info.yaml b/data/templates/cpp/.template-info.yaml index 8d36e24..9c71500 100644 --- a/data/templates/cpp/.template-info.yaml +++ b/data/templates/cpp/.template-info.yaml @@ -1,6 +1,6 @@ name: "cpp" description: "An application template for the C++ language." dependencies: - compile: [ g++, make ] - runtime: [ libstdc++, json-c ] + compile: [g++, make] + runtime: [libstdc++, json-c] base: _base diff --git a/data/templates/go/.template-info.yaml b/data/templates/go/.template-info.yaml index 7ef3b0b..9bc13ed 100644 --- a/data/templates/go/.template-info.yaml +++ b/data/templates/go/.template-info.yaml @@ -1,6 +1,6 @@ name: "go" description: "An application template for the Go language." dependencies: - compile: [ go, make ] - runtime: [ json-c ] + compile: [go, make] + runtime: [json-c] base: _base diff --git a/doc/BUILD.md b/doc/BUILD.md new file mode 100644 index 0000000..135047d --- /dev/null +++ b/doc/BUILD.md @@ -0,0 +1,85 @@ +# Build + +## Prerequisites + +| Requirement | Version | Notes | +| ----------- | -------- | ---------------------------------------------- | +| Go | ≥ 1.21 | Required to build from source | +| Docker | ≥ 23.0 | Required to build application container images | +| Docker BuildKit | ≥ 0.11 | Required for `docker build --output` | +| make | any | Used to drive the build and install targets | + +## Build locally + +Clone the repository and run: + +```shell +make +``` + +The compiled binary is placed in the `dist/` directory. + +## Build using Docker + +If you do not have a local Go toolchain, you can build entirely inside Docker +(BuildKit is required): + +```shell +docker build --output ./dist . +``` + +### Installing Docker BuildKit + +On Docker Engine < 23.0, BuildKit must be enabled manually. On Ubuntu 22.04: + +```shell +sudo apt-get install docker-buildx-plugin +``` + +Then either prefix each `docker build` invocation with `DOCKER_BUILDKIT=1`, or +follow the [official instructions](https://docs.docker.com/build/buildkit/#getting-started) +to enable it globally. + +## Build the corteca-cli container image + +`corteca-cli.Dockerfile` produces a self-contained runtime image that bundles +the corteca binary together with the Docker CLI (required for +`docker buildx build`). This is the image published to GHCR on every merge to +`main`. + +### Build the image locally + +The image expects pre-built binaries in `dist/bin/`. Build them first, then +build the image: + +```shell +VERSION=$(git describe --tags 2>/dev/null || echo "dev") + +# Build the Linux binary for your host architecture (amd64 or arm64) +make DESTOS=linux DESTARCH=amd64 + +docker build \ + -f corteca-cli.Dockerfile \ + --platform linux/amd64 \ + --build-arg VERSION=$VERSION \ + -t corteca-cli:local . +``` + +### Test the image + +Corteca needs access to the host Docker daemon at runtime, so mount the Docker +socket: + +```shell +# Verify the version reported by the binary +docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + corteca-cli:local --version + +# Open an interactive session with a project directory mounted +docker run --rm -it \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v $(pwd):/workspace \ + -w /workspace \ + corteca-cli:local +``` diff --git a/doc/Configuration.md b/doc/Configuration.md index 977cc35..1436cf0 100644 --- a/doc/Configuration.md +++ b/doc/Configuration.md @@ -79,7 +79,7 @@ app: runtime: {} # OCI runtime spec overrides (see OCI Runtime Specification) ``` -### Fields +### App Fields | Field | Type | Description | | ------- | ------ | ------------- | @@ -119,7 +119,7 @@ build: args: [ , … ] # Arguments passed to the QEMU helper ``` -### Fields +### Build Fields | Field | Type | Default | Description | | ------- | ------ | --------- | ------------- | @@ -275,10 +275,10 @@ meaningful. The fields shared by all device types are: -| Field | Type | Description | -| ------- | ------ | ------------- | +| Field | Type | Description | +| ------- | ------ | ------------- | | `addr` | string (template) | **(Required)** Connection URL. Its scheme (`ssh://`, `cwmp://`, `cwmps://`) selects the device type. | -| `architecture` | string | Architecture identifier matching an entry in `build.architectures`. | +| `architecture` | string | Architecture identifier matching an entry in `build.architectures`. | --- @@ -299,13 +299,13 @@ devices: privateKeyFile: # Path to a PEM-encoded private key ``` -| Field | Type | Required | Description | -| ------- | ------ | -------- | ------------- | +| Field | Type | Required | Description | +| ------- | ------ | -------- | ------------- | | `addr` | string (template) | Yes | SSH connection URL. Must use the `ssh://` scheme. Username and password may be embedded (e.g., `ssh://user:pass@host`). | -| `username` | string (template) | No | SSH username. Overrides any username embedded in `addr`. | -| `password` | string (template) | No | SSH password. Falls back to the password in `addr`, then prompts interactively if absent. | -| `password2` | string (template) | No | Secondary (escalation) password required by certain device firmware (e.g., for Quagga deactivation). | -| `privateKeyFile` | string (template) | No | Path to a PEM-encoded private key file. When provided, public-key authentication is attempted first. | +| `username` | string (template) | No | SSH username. Overrides any username embedded in `addr`. | +| `password` | string (template) | No | SSH password. Falls back to the password in `addr`, then prompts interactively if absent. | +| `password2` | string (template) | No | Secondary (escalation) password required by certain device firmware (e.g., for Quagga deactivation). | +| `privateKeyFile` | string (template) | No | Path to a PEM-encoded private key file. When provided, public-key authentication is attempted first. | --- @@ -371,7 +371,7 @@ sequences: | Field | Type | Default | Description | | ------- | ------ | --------- | ------------- | | `cmd` | string (template) | — | **(Required)** Command to execute. Interpreted differently per device type; see below. | -| `delay` | string (duration) | `0` | Time to wait after the step (or each retry) completes. Refer to [this](https://pkg.go.dev/time#ParseDuration) for syntax. | +| `delay` | string (duration) | `0` | Time to wait after the step (or each retry) completes. Refer to [this](https://pkg.go.dev/time#ParseDuration) for syntax. | | `timeout` | string (duration) | 5 minutes | Maximum execution time. If exceeded, the step is considered failed. Same syntax as `delay`. | | `retries` | uint | `0` | How many additional attempts to make if the step fails. | | `ignoreFailure` | bool | `false` | When `true`, a failing step does not abort the sequence. | @@ -434,7 +434,7 @@ templates: Each key is a path to a Go template file (relative to the `.corteca` directory inside the project), and the corresponding value is the output path where the rendered result should be written. -### Fields +### Templates Fields | Field | Description | | ------- | ------------- | @@ -456,7 +456,7 @@ ${ ["":].[:[:]] } ``` | Part | Description | -|------|-------------| +| --- | --- | | `"":` | Optional literal string prepended to each resolved value. | | `.` | Dot-separated path into the context (e.g., `.app.name`, `.env.HOME`). | | `:` | For **map** fields: separator between each key and its value (default: space). | @@ -465,7 +465,7 @@ ${ ["":].[:[:]] } Examples: | Expression | Result | -|------------|--------| +| --- | --- | | `${ .app.name }` | Value of `app.name` | | `${ .app.version }` | Value of `app.version` | | `${ "v":.app.version }` | `v` prepended to `app.version` (e.g., `v1.2.0`) | @@ -481,7 +481,7 @@ Template fields can reference other template fields; circular references will ca ### `.app` — Application Settings | Field | Type | Description | -|-------|------|-------------| +| --- | --- | --- | | `.app.name` | string | Application name. | | `.app.author` | string | Application author. | | `.app.version` | string | Application version string. | @@ -494,7 +494,7 @@ Template fields can reference other template fields; circular references will ca ### `.build` — Build Settings | Field | Type | Description | -|-------|------|-------------| +| --- | --- | --- | | `.build.default` | string | Default target architecture name. | | `.build.options.outputType` | string | Build output type (`rootfs`, `oci`, or `docker`). | | `.build.options.debug` | bool | Whether debug mode is enabled. | @@ -507,14 +507,14 @@ Template fields can reference other template fields; circular references will ca ### `.arch` and `.platform` — Current Architecture | Field | Type | Description | -|-------|------|-------------| +| --- | --- | --- | | `.arch` | string | Name of the target architecture currently being built (e.g., `aarch64`). Set during `build`, `regen`, and `exec`. | | `.platform` | string | Docker platform string for the current architecture (e.g., `linux/arm64`). Set alongside `.arch`. | ### `.artifact` — Build Artifact | Field | Type | Description | -|-------|------|-------------| +| --- | --- | --- | | `.artifact` | string | Absolute path to the current build artifact (e.g., `/project/dist/app-1.0-aarch64-oci.tar`). Set during `publish` and `exec`. | ### `.publish` — Active Publish Target @@ -522,7 +522,7 @@ Template fields can reference other template fields; circular references will ca Populated when a publish target is selected (during `publish` or `exec` with a publish target). | Field | Type | Description | -|-------|------|-------------| +| --- | --- | --- | | `.publish.name` | string | Alias name of the active publish target. | | `.publish.method` | string | Delivery method of the active publish target. | @@ -531,7 +531,7 @@ Populated when a publish target is selected (during `publish` or `exec` with a p Populated when a device is selected (during `exec`). | Field | Type | Description | -|-------|------|-------------| +| --- | --- | --- | | `.device.name` | string | Alias name of the active device. | | `.device.addr` | string (template) | Connection URL of the device. | | `.device.architecure` | string | Architecture identifier of the device. | @@ -539,7 +539,7 @@ Populated when a device is selected (during `exec`). ### `.env` — Host Environment Variables | Field | Type | Description | -|-------|------|-------------| +| --- | --- | --- | | `.env` | map | All host environment variables at the time the command was invoked. Access individual variables as `.env.` (e.g., `.env.HOME`, `.env.PATH`). | --- diff --git a/doc/GettingStarted.md b/doc/GettingStarted.md index 416bf92..3b25fed 100644 --- a/doc/GettingStarted.md +++ b/doc/GettingStarted.md @@ -1,10 +1,11 @@ # Getting Started ## Install corteca cli + To install or build from source, you can follow the [relevant instructions](../README.md#install) in the main README file. To make sure everything is in place, run the following command in any folder. -```bash -$ corteca config get +```shell +corteca config get ``` You should see your default system-wide configuration. @@ -18,8 +19,8 @@ on how to write templates, refer to the [relevant guide](templates.md). We will Change to the parent folder where you want your application directory to be created and run the following command: -```bash -$ corteca create test_app +```shell +corteca create test_app ``` Follow the prompt instructions to populate the application settings. A @@ -27,7 +28,7 @@ folder named after the given parameter (test_app) in the above command will be created with all the generated files inside. The structure of the folder should be similar to the below: -```bash +```shell test_app ├── .corteca │   ├── ADF.template @@ -67,20 +68,22 @@ generated. Corteca cli provides a publish functionality to help users upload their produced artifacts in an OCI registry. There are some registries set up by default in corteca that can be used as a template from the user to set up the target registry. You can check the current configuration for publish using -```bash -$ corteca config get publish +```shell +corteca config get publish ``` #### Publish in corteca local registry + User can use the already set up local registry. `corteca publish localRegistry armv7l` -Corteca will set up a local oci registry and will upload the artifact. (check with https://localhost:8080/v2/_catalog). If this registry is needed to be visible outside the host device, publicURL needs to be changed to the device IP. +Corteca will set up a local oci registry and will upload the artifact. (check with [localhost:8080/v2/_catalog](https://localhost:8080/v2/_catalog)). If this registry is needed to be visible outside the host device, publicURL needs to be changed to the device IP. `corteca config set publish.localRegistry.publicURL http://192.168.18.2:8080` #### Publish in custom registry + User can add his own registry using *corteca config* `corteca config add publish "{myregistry: { addr: https://my-registry.com, auth: basic, username: user1, password: pass1, method: push}}"` @@ -94,9 +97,8 @@ After the registry is set up, the application can be uploaded. For more info on corteca publish check [corteca publish](reference/corteca_publish.md) guide. ## Configuring application -Application configuration is noted in the corteca.yaml file placed in application's folder when created. The *app* section -related to the create command- specifies app settings you can alter during the build process. -The complete set of settings for Corteca Cli is created by combining this file with /etc/corteca/corteca.yaml. -Corteca cli provides methods to show or edit these settings using the *config* method. You can also check or edit the settings values using the auto complete of Corteca cli by pressing key twice like in a shell. + +Application configuration is noted in the corteca.yaml file placed in application's folder when created. The *app* section -related to the create command- specifies app settings you can alter during the build process. The complete set of settings for Corteca Cli is created by combining this file with /etc/corteca/corteca.yaml. Corteca cli provides methods to show or edit these settings using the *config* method. You can also check or edit the settings values using the auto complete of Corteca cli by pressing key twice like in a shell. `corteca config get app` @@ -108,6 +110,4 @@ To set a value `corteca config set app.version 1.0.1` - - For more detailed guide on config get/set/add please refer to [corteca config](reference/corteca_config.md) guide. diff --git a/doc/INSTALL.md b/doc/INSTALL.md new file mode 100644 index 0000000..123b687 --- /dev/null +++ b/doc/INSTALL.md @@ -0,0 +1,34 @@ +# Install + +## Install manually from source + +You can run the below command to build the application and install the binary to +the `/usr/bin` folder and the default configuration files to the `/etc/corteca` +folder: + +```shell +sudo make install +``` + +To remove a previous manual installation + +```shell +sudo make uninstall +``` + +You can customize the destination folder by overriding the `$DESTDIR` +environment variable: + +```shell +# no sudo required; will be installed to ~/.local/share/usr/bin +DESTDIR=~/.local/share make install +``` + +## Install with package manager + +If you are using debian/ubuntu or redhat-based distributions, you can create a relevant package and let your package manager handle installation. E.g. for ubuntu: + +```shell +make deb +make rpm +``` diff --git a/doc/USAGE.md b/doc/USAGE.md deleted file mode 100644 index 873b5d2..0000000 --- a/doc/USAGE.md +++ /dev/null @@ -1,52 +0,0 @@ -# Corteca Command Line Interface - -- [Corteca Command Line Interface](#corteca-command-line-interface) - - [Available commands](#available-commands) - - [Configuration](#configuration) - - [Application Templates](#application-templates) - -## Available commands - -- [`corteca build`](reference/corteca_build.md) -- [`corteca config`](reference/corteca_config.md) - - [`corteca config add`](reference/corteca_config_add.md) - - [`corteca config get`](reference/corteca_config_get.md) - - [`corteca config set`](reference/corteca_config_set.md) -- [`corteca create`](reference/corteca_create.md) -- [`corteca exec`](reference/corteca_exec.md) -- [`corteca publish`](reference/corteca_publish.md) -- [`corteca regen`](reference/corteca_regen.md) - -**Note**: For essential guidance on using the Corteca CLI, refer to `corteca --help` for general information, or `corteca --help` for details on specific commands. - -## Configuration - -Configuration values are read cascadingly from the following sources: - -1. System-wide configuration: `/etc/corteca/corteca.yaml` -1. User global configuration: `$HOME/.config/corteca/corteca.yaml` -1. user local (project) configuration: `./corteca.yaml` - -The last is searched in the working directory; if not found, it will continue searching in the parent folder(s), until it finds a configuration file or reaches filesystem root. Thus, provided that a local configuration file exists in the project root folder, `corteca` commands can be run from any subfolder inside that. - -## Application Templates - -`corteca` scans for available templates in a `templates/` folder that resides in the global config folder (defaults to `$HOME/.config/corteca`). You can override the global config folder using the `--configRoot` flag. - -Each subfolder containing a `.template-info.yaml` file will be treated as a template and will be rendered with all configuration variables available for rendering. For more information, see [configuration section](#configuration) above. - -Here is an overview of the content of `.template-info.yaml` - -```yaml - # Name of the template - name: "foobar" - description: "Short template description" - dependencies: - compile: [ foo ] # Dependencies needed during compile - runtime: [ bar ] # Dependencies needed during runtime - # Dynamic files that need regeneration during the project's lifecycle - regenFiles: - .corteca/foo.template: foo # Mapping template filepath to regenerated filepath - # A collection of custom options available for rendering - options: -``` diff --git a/doc/reference/corteca_create.md b/doc/reference/corteca_create.md index 62ff129..b147d30 100644 --- a/doc/reference/corteca_create.md +++ b/doc/reference/corteca_create.md @@ -1,6 +1,6 @@ # `create` -The create command is used to generate a new application skeleton/template based on a selected programming language (see details about templates [here](../USAGE.md#application-templates)). This command can be configured interactively or through specified flags. +The create command is used to generate a new application skeleton/template based on a selected programming language (see details about [application templates](../USAGE.md#application-templates)). This command can be configured interactively or through specified flags. ## Usage diff --git a/doc/templates.md b/doc/templates.md index b627d1e..2616a27 100644 --- a/doc/templates.md +++ b/doc/templates.md @@ -2,7 +2,7 @@ `corteca` scans for available templates in a `templates/` folder that resides in the global config folder (defaults to `$HOME/.config/corteca`). You can override the global config folder using the `--configRoot` flag. -Each subfolder containing a `.template-info.yaml` file will be treated as a template and will be rendered with all configuration variables available for rendering. For more information, see [configuration section](#configuration) above. +Each subfolder containing a `.template-info.yaml` file will be treated as a template and will be rendered with all configuration variables available for rendering. For more information, see the configuration section above. Here is an overview of the content of `.template-info.yaml` diff --git a/internal/configuration/configuration.go b/internal/configuration/configuration.go index 07ce055..a89ef8f 100755 --- a/internal/configuration/configuration.go +++ b/internal/configuration/configuration.go @@ -359,9 +359,8 @@ func (conf *Settings) ReadFromFile(path string) error { if err != nil { return err } - defer in.Close() + defer func() { _ = in.Close() }() if err = ReadYamlInto(conf, in); err != nil { - in.Close() return err } @@ -810,7 +809,7 @@ func readTemplateInfo(fullPath string) (info TemplateInfo, err error) { if err != nil { return TemplateInfo{}, err } - defer yamlData.Close() + defer func() { _ = yamlData.Close() }() if err = ReadYamlInto(&info, yamlData); err != nil { return diff --git a/internal/configuration/sequence.go b/internal/configuration/sequence.go index 6e76064..ad0ae25 100644 --- a/internal/configuration/sequence.go +++ b/internal/configuration/sequence.go @@ -129,7 +129,9 @@ func (sm *SequenceMap) executeSequenceSteps(executor CommandExecutor, seqName st // TODO: provide option to suppress output tui.SetOutputColor(tui.CBlue, os.Stdout) enc := yaml.NewEncoder(os.Stdout) - enc.Encode(res) + if err := enc.Encode(res); err != nil { + return fmt.Errorf("failed to encode step result: %w", err) + } tui.ResetOutputColor(os.Stdout) } return nil diff --git a/internal/configuration/templating/templating.go b/internal/configuration/templating/templating.go index 09688df..ade8fe1 100644 --- a/internal/configuration/templating/templating.go +++ b/internal/configuration/templating/templating.go @@ -94,7 +94,7 @@ func RenderTemplateToFile(fs afero.Fs, srcFile, destFile string, context any) er if err != nil { return err } - defer out.Close() + defer func() { _ = out.Close() }() if err := fileTmpl.Execute(out, context); err != nil { return err diff --git a/internal/configuration/templating/templating_test.go b/internal/configuration/templating/templating_test.go index f9bd9bb..8455159 100644 --- a/internal/configuration/templating/templating_test.go +++ b/internal/configuration/templating/templating_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/spf13/afero" + "github.com/stretchr/testify/require" ) func TestRenderTemplateFile(t *testing.T) { @@ -18,8 +19,8 @@ func TestRenderTemplateFile(t *testing.T) { templateContent := "Hello, {{.Name}}!" templateDir := "/project" templateFile := "template.txt" - fs.MkdirAll(templateDir, 0755) - afero.WriteFile(fs, filepath.Join(templateDir, templateFile), []byte(templateContent), 0644) + require.NoError(t, fs.MkdirAll(templateDir, 0755)) + require.NoError(t, afero.WriteFile(fs, filepath.Join(templateDir, templateFile), []byte(templateContent), 0644)) testCases := []struct { name string diff --git a/internal/device/cwmp/cwmpdevice.go b/internal/device/cwmp/cwmpdevice.go index 015845f..847d8f5 100644 --- a/internal/device/cwmp/cwmpdevice.go +++ b/internal/device/cwmp/cwmpdevice.go @@ -197,9 +197,9 @@ func (c *CWMPDevice) sendConnectionRequest(cpe *configuration.HttpClientEndpoint if err != nil { return fmt.Errorf("error sending connection request: %w", err) } - defer resp.Body.Close() - c.log.Write(fmt.Appendf([]byte(""), "[%s] Connection Request (response: %s)\n", time.Now().Format(time.DateTime), resp.Status)) - io.Copy(io.Discard, resp.Body) + defer func() { _ = resp.Body.Close() }() + _, _ = c.log.Write(fmt.Appendf([]byte(""), "[%s] Connection Request (response: %s)\n", time.Now().Format(time.DateTime), resp.Status)) + _, _ = io.Copy(io.Discard, resp.Body) tui.LogNormal("Connection Request sent to %s; status code: %d", url.String(), resp.StatusCode) return nil } @@ -227,9 +227,10 @@ func (d *CWMPDevice) initServer(config *configuration.HttpServerEndpoint) error // Run server in a goroutine go func() { var err error - if u.Scheme == "http" { + switch u.Scheme { + case "http": err = d.server.ListenAndServe() - } else if u.Scheme == "https" { + case "https": err = d.server.ListenAndServeTLS(config.Certificate.String(), config.Key.String()) } if err != nil && err != http.ErrServerClosed { @@ -248,7 +249,7 @@ func (d *CWMPDevice) handleHTTPRequest(w http.ResponseWriter, r *http.Request) { return } - d.log.Write(fmt.Appendf([]byte(""), "[%s] IN: %s %s %s\n", + _, _ = d.log.Write(fmt.Appendf([]byte(""), "[%s] IN: %s %s %s\n", time.Now().Format(time.DateTime), r.Method, r.RequestURI, @@ -264,7 +265,7 @@ func (d *CWMPDevice) handleHTTPRequest(w http.ResponseWriter, r *http.Request) { return } } else { - d.log.Write([]byte("\n")) + _, _ = d.log.Write([]byte("\n")) if len(env.Body.Messages) > 1 { tui.LogNormal("%d messages received; ignoring all but first", len(env.Body.Messages)) } else if len(env.Body.Messages) == 0 { @@ -276,10 +277,10 @@ func (d *CWMPDevice) handleHTTPRequest(w http.ResponseWriter, r *http.Request) { d.SetSessionID(env.GetID()) } - d.log.Write(fmt.Appendf([]byte(""), "[%s] OUT:\n", time.Now().Format(time.DateTime))) + _, _ = d.log.Write(fmt.Appendf([]byte(""), "[%s] OUT:\n", time.Now().Format(time.DateTime))) resp := <-d.out d.writeHTTPResponse(w, http.StatusOK, resp) - d.log.Write([]byte("\n--------------------------------------------------------------------------------\n")) + _, _ = d.log.Write([]byte("\n--------------------------------------------------------------------------------\n")) } // write a reply to the response diff --git a/internal/device/cwmp/messages/ChangeDUState_test.go b/internal/device/cwmp/messages/ChangeDUState_test.go index cc49e39..5cfb686 100644 --- a/internal/device/cwmp/messages/ChangeDUState_test.go +++ b/internal/device/cwmp/messages/ChangeDUState_test.go @@ -6,12 +6,12 @@ package messages_test import ( "bytes" - "github.com/nokia/corteca-cli/internal/configuration" - c "github.com/nokia/corteca-cli/internal/configuration" - "github.com/nokia/corteca-cli/internal/device/cwmp/messages" "encoding/xml" "testing" + c "github.com/nokia/corteca-cli/internal/configuration" + "github.com/nokia/corteca-cli/internal/device/cwmp/messages" + "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) @@ -91,10 +91,10 @@ var ChangeDUStateInputMsg = messages.ChangeDUState{ } func setupContext() { - configuration.ResetContext() - configuration.GetCmdContext().App.Name = "image" - configuration.GetCmdContext().App.Version = "1.0.0" - configuration.GetCmdContext().App.DUID = "c0c4328b-18a4-4b3b-b1da-e8ea8d8f457d" + c.ResetContext() + c.GetCmdContext().App.Name = "image" + c.GetCmdContext().App.Version = "1.0.0" + c.GetCmdContext().App.DUID = "c0c4328b-18a4-4b3b-b1da-e8ea8d8f457d" } func TestChangeDUStateParseFromXML(t *testing.T) { diff --git a/internal/device/ssh/mock_ssh_server_test.go b/internal/device/ssh/mock_ssh_server_test.go index 9252f92..786e2b4 100644 --- a/internal/device/ssh/mock_ssh_server_test.go +++ b/internal/device/ssh/mock_ssh_server_test.go @@ -86,7 +86,7 @@ func startTestServer(t *testing.T, expectedUsername, password string, authorized if err != nil { t.Fatalf("startTestServer: listen: %v", err) } - t.Cleanup(func() { ln.Close() }) + t.Cleanup(func() { _ = ln.Close() }) go func() { for { @@ -106,12 +106,12 @@ func serveConn(conn net.Conn, cfg *ssh.ServerConfig, handler cmdHandlerFunc) { if err != nil { return // auth failure or protocol error — nothing to do } - defer sshConn.Close() + defer func() { _ = sshConn.Close() }() go ssh.DiscardRequests(reqs) for newChan := range chans { if newChan.ChannelType() != "session" { - newChan.Reject(ssh.UnknownChannelType, "unsupported channel type") + _ = newChan.Reject(ssh.UnknownChannelType, "unsupported channel type") continue } ch, requests, err := newChan.Accept() @@ -127,22 +127,22 @@ func serveSession(ch ssh.Channel, reqs <-chan *ssh.Request, handler cmdHandlerFu // exec request, the channel is closed. This causes the client-side // s.wait goroutine's "for msg := range reqs" loop to exit, which populates // s.exitStatus and unblocks session.Wait() — preventing a deadlock. - defer ch.Close() + defer func() { _ = ch.Close() }() for req := range reqs { switch req.Type { case "exec": if len(req.Payload) < 4 { - req.Reply(false, nil) + _ = req.Reply(false, nil) continue } n := binary.BigEndian.Uint32(req.Payload[:4]) if uint32(len(req.Payload)) < 4+n { - req.Reply(false, nil) + _ = req.Reply(false, nil) continue } cmd := string(req.Payload[4 : 4+n]) - req.Reply(true, nil) + _ = req.Reply(true, nil) // Call the handler synchronously. For normal commands this returns // quickly. For the context-cancellation test the handler blocks on @@ -166,7 +166,7 @@ func serveSession(ch ssh.Channel, reqs <-chan *ssh.Request, handler cmdHandlerFu // other channel requests the client may send while the handler is // running or between commands. if req.WantReply { - req.Reply(false, nil) + _ = req.Reply(false, nil) } } } diff --git a/internal/device/ssh/sshdevice.go b/internal/device/ssh/sshdevice.go index ed899e3..2c79b0e 100644 --- a/internal/device/ssh/sshdevice.go +++ b/internal/device/ssh/sshdevice.go @@ -20,7 +20,6 @@ import ( "sync" "time" - "golang.org/x/crypto/ssh" stdssh "golang.org/x/crypto/ssh" ) @@ -41,10 +40,6 @@ const ( deactivateQuaggaCmd = "sed -i 's#/usr/bin/vtysh#/bin/ash#' /etc/passwd" maxNumRetries = 3 defaultSSHPort = "22" - - authSSHPassword = "password" - authSSHPublicKey = "publicKey" - cmdCPUArch = "uname -m" ) type SSHDevice struct { @@ -64,7 +59,9 @@ func NewSSHDevice(c *configuration.DeviceConfig, log io.Writer) (device.Device, err error sshconfig configuration.SSHClientEndpoint ) - c.Decode(&sshconfig) + if err = c.Decode(&sshconfig); err != nil { + return nil, err + } if err = d.connectSSHClient(&sshconfig); err != nil { return nil, err @@ -78,7 +75,7 @@ func NewSSHDevice(c *configuration.DeviceConfig, log io.Writer) (device.Device, if err := deactivateQuagga(d.client, passwd2); err != nil { return nil, err } - d.client.Close() + _ = d.client.Close() tui.LogNormal("Need to reconnect...") return NewSSHDevice(c, log) } @@ -115,7 +112,7 @@ func (d *SSHDevice) executeCommandString(ctx context.Context, cmd string) (any, if err != nil { return nil, fmt.Errorf("cannot start SSH command session: %w", err) } - defer session.Close() + defer func() { _ = session.Close() }() output := bytes.NewBuffer(make([]byte, 0, 512)) session.Stdout = io.MultiWriter(d.log, output) session.Stderr = d.log @@ -141,7 +138,7 @@ func (d *SSHDevice) executeCommandString(ctx context.Context, cmd string) (any, } case <-ctx.Done(): - _ = session.Signal(ssh.SIGKILL) + _ = session.Signal(stdssh.SIGKILL) return nil, ctx.Err() } } @@ -213,7 +210,7 @@ func (d *SSHDevice) connectSSHClient(sshconfig *configuration.SSHClientEndpoint) // connect to stdssh server d.client, err = stdssh.Dial("tcp", u.Host, config) if err != nil { - fmt.Fprintf(d.log, "\n=== New connection to %s at %s ===\n", u.Host, time.Now().Format(time.DateTime)) + _, _ = fmt.Fprintf(d.log, "\n=== New connection to %s at %s ===\n", u.Host, time.Now().Format(time.DateTime)) } return err } @@ -223,7 +220,7 @@ func hasQuagga(client *stdssh.Client) (bool, error) { if err != nil { return false, err } - defer session.Close() + defer func() { _ = session.Close() }() var output bytes.Buffer session.Stdout = &output @@ -248,7 +245,7 @@ func deactivateQuagga(client *stdssh.Client, password2 string) error { if err != nil { return err } - defer session.Close() + defer func() { _ = session.Close() }() if err := session.RequestPty("xterm", 80, 40, stdssh.TerminalModes{}); err != nil { return err @@ -275,5 +272,5 @@ func deactivateQuagga(client *stdssh.Client, password2 string) error { } func (d *SSHDevice) Close() { - d.client.Close() + _ = d.client.Close() } diff --git a/internal/device/ssh/sshdevice_test.go b/internal/device/ssh/sshdevice_test.go index 1df3ccb..feefc5b 100644 --- a/internal/device/ssh/sshdevice_test.go +++ b/internal/device/ssh/sshdevice_test.go @@ -179,7 +179,7 @@ func TestSSHDevice_PublicKeyAuth(t *testing.T) { if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil { t.Fatalf("write PEM key: %v", err) } - keyFile.Close() + _ = keyFile.Close() // Start a server that only accepts the generated public key (no password). addr := startTestServer(t, "testuser", "", sshPubKey, withQuaggaProbe(func(cmd string) (string, uint32) { diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go index 5be3529..0c3455f 100644 --- a/internal/fsutil/fsutil.go +++ b/internal/fsutil/fsutil.go @@ -26,7 +26,7 @@ func CopyFile(src, dst string) (int64, error) { if err != nil { return 0, err } - defer source.Close() + defer func() { _ = source.Close() }() destDir := filepath.Dir(dst) if err := os.MkdirAll(destDir, os.ModePerm); err != nil { @@ -37,7 +37,7 @@ func CopyFile(src, dst string) (int64, error) { if err != nil { return 0, err } - defer destination.Close() + defer func() { _ = destination.Close() }() nBytes, err := io.Copy(destination, source) if err != nil { return nBytes, err @@ -78,13 +78,13 @@ func TarAndGzip(basePath, targetTarGzPath string, includePaths []string) error { if err != nil { return err } - defer tarFile.Close() + defer func() { _ = tarFile.Close() }() gzipWriter := gzip.NewWriter(tarFile) - defer gzipWriter.Close() + defer func() { _ = gzipWriter.Close() }() tarWriter := tar.NewWriter(gzipWriter) - defer tarWriter.Close() + defer func() { _ = tarWriter.Close() }() for _, includePath := range includePaths { fullPath := filepath.Join(basePath, includePath) @@ -150,7 +150,7 @@ func addFileToTarWriter(tarWriter *tar.Writer, file string, fi os.FileInfo, base if err != nil { return err } - defer fileContent.Close() + defer func() { _ = fileContent.Close() }() if _, err := io.Copy(tarWriter, fileContent); err != nil { return err @@ -166,14 +166,14 @@ func ExtractTarball(src, dest string) error { if err != nil { return fmt.Errorf("could not open source file: %v", err) } - defer file.Close() + defer func() { _ = file.Close() }() // Create a gzip reader gzipReader, err := gzip.NewReader(file) if err != nil { return fmt.Errorf("could not create gzip reader: %v", err) } - defer gzipReader.Close() + defer func() { _ = gzipReader.Close() }() // Create a tar reader tarReader := tar.NewReader(gzipReader) @@ -207,7 +207,7 @@ func ExtractTarball(src, dest string) error { if err != nil { return fmt.Errorf("could not create file: %v", err) } - defer outFile.Close() + defer func() { _ = outFile.Close() }() err = os.Chmod(targetPath, header.FileInfo().Mode()) if err != nil { @@ -242,16 +242,16 @@ func CreateTarArchive(fileName, archivePath string) error { if err != nil { return fmt.Errorf("failed to open file: %w", err) } - defer file.Close() + defer func() { _ = file.Close() }() archive, err := os.Create(archivePath) if err != nil { return fmt.Errorf("failed to create archive file: %w", err) } - defer archive.Close() + defer func() { _ = archive.Close() }() writer := tar.NewWriter(archive) - defer writer.Close() + defer func() { _ = writer.Close() }() fileInfo, err := file.Stat() if err != nil { @@ -292,13 +292,13 @@ func CreateTarArchiveFromFolder(srcDir, destTarGzPath string) error { if err != nil { return fmt.Errorf("failed to create archive file: %w", err) } - defer archive.Close() + defer func() { _ = archive.Close() }() gzipWriter := gzip.NewWriter(archive) - defer gzipWriter.Close() + defer func() { _ = gzipWriter.Close() }() tarWriter := tar.NewWriter(gzipWriter) - defer tarWriter.Close() + defer func() { _ = tarWriter.Close() }() baseDir := filepath.Clean(srcDir) return filepath.Walk(srcDir, func(file string, fi os.FileInfo, err error) error { @@ -333,7 +333,7 @@ func CreateTarArchiveFromFolder(srcDir, destTarGzPath string) error { if err != nil { return err } - defer fileContent.Close() + defer func() { _ = fileContent.Close() }() if _, err := io.Copy(tarWriter, fileContent); err != nil { return err diff --git a/internal/packager/oci.go b/internal/packager/oci.go index 41d4df8..932f926 100644 --- a/internal/packager/oci.go +++ b/internal/packager/oci.go @@ -31,7 +31,7 @@ func writeBuildInfoJson(buildInfo map[string]string, rootfsPath string) error { if err != nil { return fmt.Errorf("failed to create file: %w", err) } - defer file.Close() + defer func() { _ = file.Close() }() _, err = file.Write(data) if err != nil { @@ -50,7 +50,7 @@ func writeRuntimeSpecToJSON(appSettings configuration.AppSettings, filePath stri if err != nil { return fmt.Errorf("failed to create file: %w", err) } - defer file.Close() + defer func() { _ = file.Close() }() _, err = file.Write(data) if err != nil { diff --git a/internal/packager/validation.go b/internal/packager/validation.go index 8d7bc1b..931f2b2 100644 --- a/internal/packager/validation.go +++ b/internal/packager/validation.go @@ -44,7 +44,7 @@ func isBinary(entrypointPath string) (bool, string, error) { if err != nil { return false, "", fmt.Errorf("failed to open file: %v", err) } - defer file.Close() + defer func() { _ = file.Close() }() is_bin := false const chunkSize = 4 buf := make([]byte, chunkSize) @@ -73,7 +73,7 @@ func isScript(entrypointPath string) (bool, string, error) { if err != nil { return false, "", fmt.Errorf("failed to open file: %v", err) } - defer file.Close() + defer func() { _ = file.Close() }() reader := bufio.NewReader(file) line, err := reader.ReadString('\n') if err != nil && err != io.EOF { @@ -186,7 +186,7 @@ func verifyMachineCompatibility(path, targetArch string) error { if err != nil { return fmt.Errorf("ELF validation failed: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() if f.Type != elf.ET_EXEC && f.Type != elf.ET_DYN { return fmt.Errorf("invalid ELF type: %s", f.Type) diff --git a/internal/packager/validation_test.go b/internal/packager/validation_test.go index a2c79c4..605e4d0 100644 --- a/internal/packager/validation_test.go +++ b/internal/packager/validation_test.go @@ -295,30 +295,18 @@ func createSymlink(t *testing.T, target, link string) { func TestDiscoverEntrypointPath_SymlinkToBinary(t *testing.T) { - tmpDir := t.TempDir() - bin := createBinaryFile(t, "", []byte{0x7F, 0x45, 0x4C, 0x46}) - tmpPathToBinary := filepath.Join(tmpDir, bin) - link := "link" - tmpPathToLink := filepath.Join(tmpDir, link) - createSymlink(t, bin, link) + tmpDir := t.TempDir() + bin := createBinaryFile(t, tmpDir, []byte{0x7F, 0x45, 0x4C, 0x46}) + tmpPathToLink := filepath.Join(tmpDir, "link") + createSymlink(t, "binaryfile", tmpPathToLink) - err := os.Rename(bin, tmpPathToBinary) + path, err := discoverEntrypointPath(tmpPathToLink, tmpDir) if err != nil { - t.Fatalf("failed to move file: %v", err) + t.Fatalf("unexpected error: %v", err) } - - err = os.Rename(link, tmpPathToLink) - if err != nil { - t.Fatalf("failed to move file: %v", err) + if path != bin { + t.Errorf("expected path to be '%s', got '%s'", bin, path) } - - path, err := discoverEntrypointPath(tmpPathToLink, tmpDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if path != tmpPathToBinary { - t.Errorf("expected path to be '%s', got '%s'", bin, path) - } } func TestDiscoverEntrypointPath_ScriptWithShebang(t *testing.T) { @@ -337,32 +325,19 @@ func TestDiscoverEntrypointPath_ScriptWithShebang(t *testing.T) { } func TestDiscoverEntrypointPath_ChainSymlinkScriptBinary(t *testing.T) { - tmpDir := t.TempDir() - bin := createBinaryFile(t, tmpDir, []byte{0x7F, 0x45, 0x4C, 0x46}) - script := createFile(t, "", "testfile.sh", "#!/binaryfile\n") - tmpPathToScript := filepath.Join(tmpDir, script) - link := "linkfile" - tmpPathToLink := filepath.Join(tmpDir, link) - createSymlink(t, script, link) + tmpDir := t.TempDir() + bin := createBinaryFile(t, tmpDir, []byte{0x7F, 0x45, 0x4C, 0x46}) + _ = createFile(t, tmpDir, "testfile.sh", "#!/binaryfile\n") + tmpPathToLink := filepath.Join(tmpDir, "linkfile") + createSymlink(t, "testfile.sh", tmpPathToLink) - err := os.Rename(script, tmpPathToScript) + path, err := discoverEntrypointPath(tmpPathToLink, tmpDir) if err != nil { - t.Fatalf("failed to move file: %v", err) + t.Fatalf("unexpected error: %v", err) } - - err = os.Rename(link, tmpPathToLink) - if err != nil { - t.Fatalf("failed to move file: %v", err) + if path != bin { + t.Errorf("expected path to be '%s', got '%s'", bin, path) } - - path, err := discoverEntrypointPath(tmpPathToLink, tmpDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - expected := bin - if path != expected { - t.Errorf("expected path to be '%s', got '%s'", expected, path) - } } func TestDiscoverEntrypointPath_UnknownFileType(t *testing.T) { diff --git a/internal/publish/listen.go b/internal/publish/listen.go index 0c1da5b..08f7830 100644 --- a/internal/publish/listen.go +++ b/internal/publish/listen.go @@ -30,7 +30,7 @@ func ListenAsync(serverRoot string, addr *url.URL) (*http.Server, error) { Handler: http.FileServer(http.Dir(serverRoot)), } go func() { - srv.Serve(l) + _ = srv.Serve(l) }() return srv, nil default: diff --git a/internal/publish/push.go b/internal/publish/push.go index c47a83f..a96fe80 100644 --- a/internal/publish/push.go +++ b/internal/publish/push.go @@ -39,7 +39,7 @@ func GzipOpener(path string) tarball.Opener { gz, err := gzip.NewReader(f) if err != nil { - f.Close() + _ = f.Close() return nil, err } diff --git a/internal/publish/put.go b/internal/publish/put.go index 3d97b3e..8443577 100644 --- a/internal/publish/put.go +++ b/internal/publish/put.go @@ -68,7 +68,7 @@ func HttpPut(filePath string, url url.URL, token string) error { if err != nil { return err } - defer file.Close() + defer func() { _ = file.Close() }() prog := tui.PromptForProgress(fmt.Sprintf("Uploading %s", fileName)) defer close(prog) @@ -98,7 +98,7 @@ func HttpPut(filePath string, url url.URL, token string) error { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return fmt.Errorf("server returned non-successful status: %s", resp.Status) diff --git a/internal/publish/put_test.go b/internal/publish/put_test.go index cabb93b..c5e3cb5 100644 --- a/internal/publish/put_test.go +++ b/internal/publish/put_test.go @@ -101,13 +101,13 @@ func TestHttpPut(t *testing.T) { if err != nil { t.Fatalf("Failed to create temporary file: %v", err) } - defer os.Remove(tmpFile.Name()) + defer func() { _ = os.Remove(tmpFile.Name()) }() _, err = tmpFile.Write([]byte("test content")) if err != nil { t.Fatalf("Failed to write to temporary file: %v", tmpFile) } - tmpFile.Close() + _ = tmpFile.Close() var server *httptest.Server if tc.setupServer != nil { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 136a0ff..0815a0f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -37,7 +37,7 @@ var ( // If no-color flag is set or no terminal found then remove colored output func DefineOutputColor() { - if DisableColoredOutput || !(term.IsTerminal(int(os.Stdout.Fd())) && term.IsTerminal(int(os.Stderr.Fd()))) { + if DisableColoredOutput || !term.IsTerminal(int(os.Stdout.Fd())) || !term.IsTerminal(int(os.Stderr.Fd())) { DisableColoredOutput = true pterm.DisableColor() } @@ -64,13 +64,13 @@ func LogError(format string, args ...any) { func SetOutputColor(colorSeq string, out io.Writer) { if !DisableColoredOutput { - fmt.Fprintf(out, colorSeq) + _, _ = fmt.Fprint(out, colorSeq) } } func ResetOutputColor(out io.Writer) { if !DisableColoredOutput { - fmt.Fprintf(out, CsReset) + _, _ = fmt.Fprint(out, CsReset) } } @@ -117,7 +117,7 @@ func PromptForProgress(label string) chan<- ProgressUpdate { diff := current - bar.Current bar.Add(diff) } - bar.Stop() + _, _ = bar.Stop() }() return ch } diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..b86cbe8 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Run the same checks as build.yml locally. +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BOLD='\033[1m' +RESET='\033[0m' + +pass() { echo -e "${GREEN}✓ $1${RESET}"; } +fail() { echo -e "${RED}✗ $1${RESET}"; exit 1; } +warn() { echo -e "${YELLOW}⚠ $1${RESET}"; } +header() { echo -e "\n${BOLD}=== $1 ===${RESET}"; } + +# ── Devcontainer guard ─────────────────────────────────────────────────────── +# Re-run inside the devcontainer if we're not already in one. +if [ -z "${IN_DEVCONTAINER:-}" ] && [ -z "${REMOTE_CONTAINERS:-}" ] && [ -z "${CODESPACES:-}" ]; then + header "Not inside devcontainer — spinning it up" + if ! command -v devcontainer &>/dev/null; then + fail "devcontainer CLI not found.\n Install: npm install -g @devcontainers/cli\n Or open this repo in VS Code and use 'Reopen in Container'." + fi + WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)" + devcontainer up --workspace-folder "$WORKSPACE" || fail "devcontainer up failed" + exec devcontainer exec --workspace-folder "$WORKSPACE" bash scripts/check.sh +fi + +# ── Build ──────────────────────────────────────────────────────────────────── +header "Build" +make || fail "build failed" +pass "build" + +# ── Tests ──────────────────────────────────────────────────────────────────── +header "Tests" +make test || fail "tests failed" +pass "tests" + +# ── Go lint ────────────────────────────────────────────────────────────────── +header "Go lint (golangci-lint)" +if ! command -v golangci-lint &>/dev/null; then + fail "golangci-lint not found — install it or open the devcontainer" +fi +golangci-lint run ./... || fail "Go lint failed" +pass "Go lint" + +# ── YAML lint ──────────────────────────────────────────────────────────────── +header "YAML lint (yamllint)" +if ! command -v yamllint &>/dev/null; then + echo "yamllint not found, installing via apt..." + sudo apt-get install -y -qq yamllint +fi +yamllint -c .yamllint.yaml . || fail "YAML lint failed" +pass "YAML lint" + +# ── Markdown lint ──────────────────────────────────────────────────────────── +header "Markdown lint (markdownlint-cli2)" +if ! command -v npx &>/dev/null; then + warn "npx / Node.js not found — skipping markdown lint (runs in CI via GitHub Actions)" +else + npx --yes markdownlint-cli2 --config .markdownlint.yaml "**/*.md" || fail "Markdown lint failed" + pass "Markdown lint" +fi + +# ───────────────────────────────────────────────────────────────────────────── +echo -e "\n${GREEN}${BOLD}All available checks passed.${RESET}"