Skip to content
Open
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
7 changes: 7 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
name: Build and test
run-name: ${{ github.actor }} is running go build / go test
on: [push]
env:
# amp-labs modules (connectors) use Git LFS; fetch them directly with git
# instead of proxy.golang.org, which serves LFS pointer files.
GOPRIVATE: "github.com/amp-labs/*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Set up Git LFS
run: git lfs install

- name: Set up go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
Expand Down
60 changes: 60 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: CodeQL
run-name: ${{ github.actor }} is running CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "27 5 * * 1"
env:
# amp-labs modules (connectors) use Git LFS; fetch them directly with git
# instead of proxy.golang.org, which serves LFS pointer files.
GOPRIVATE: "github.com/amp-labs/*"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: manual
- language: ruby
build-mode: none
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Set up Git LFS
if: matrix.language == 'go'
run: git lfs install

- name: Set up go
if: matrix.language == 'go'
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod
cache: false

- name: Initialize CodeQL
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}

- name: Build
if: matrix.build-mode == 'manual'
run: go build ./...

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
with:
category: "/language:${{ matrix.language }}"
6 changes: 6 additions & 0 deletions .github/workflows/linter.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
name: Linter
run-name: ${{ github.actor }} is running the linter
on: [push]
env:
# amp-labs modules (connectors) use Git LFS; fetch them directly with git
# instead of proxy.golang.org, which serves LFS pointer files.
GOPRIVATE: "github.com/amp-labs/*"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Set up Git LFS
run: git lfs install
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod
Expand Down
201 changes: 201 additions & 0 deletions cmd/validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package cmd

import (
"context"
"errors"
"os"

ampyaml "github.com/amp-labs/amp-yaml-validator"
"github.com/amp-labs/amp-yaml-validator/catalog"
"github.com/amp-labs/cli/files"
"github.com/amp-labs/cli/flags"
"github.com/amp-labs/cli/logger"
"github.com/amp-labs/cli/request"
"github.com/amp-labs/cli/validate"
"github.com/spf13/cobra"
)

var (
validateStrict bool
validateSkipProvider bool
validateSkipAsync bool
)

var validateCmd = &cobra.Command{ //nolint:gochecknoglobals
Use: "validate [ampYamlSourcePath]",
Short: "Validate an amp.yaml manifest",
Long: "Validate an amp.yaml manifest without deploying it.\n\n" +
"You can provide a path to the folder that contains amp.yaml or a path to the file " +
"itself; if omitted the current directory is used.\n\n" +
"When a project is configured (via --project), destinations and provider apps " +
"referenced by the manifest are checked against your Ampersand project. Without a " +
"project only schema and best-practice checks run.",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
source := "."
if len(args) > 0 {
source = args[0]
}

manifestPath, err := files.FindManifestFile(source)
if err != nil {
if errors.Is(err, files.ErrBadManifest) {
logger.Fatal(err.Error())
}

logger.FatalErr("Unable to locate manifest", err)
}

result, err := ampyaml.ValidateFile(cmd.Context(), manifestPath, buildValidateOptions(cmd.Context())...)
if err != nil {
logger.FatalErr("Unable to validate manifest", err)
}

printValidationResult(manifestPath, result)

if !result.Valid {
os.Exit(1)
}
},
}

// buildValidateOptions assembles the validator options from the command flags and,
// when a project is configured, the API-backed checkers.
func buildValidateOptions(ctx context.Context) []ampyaml.Option {
var opts []ampyaml.Option

if validateStrict {
opts = append(opts, ampyaml.WithStrictMode(true))
}

if validateSkipProvider {
opts = append(opts, ampyaml.WithSkipProviderValidation())
}

if validateSkipAsync {
opts = append(opts, ampyaml.WithSkipAsyncValidation())
}

opts = append(opts, catalogOption(ctx))
opts = append(opts, apiCheckerOptions(ctx)...)

return opts
}

// catalogOption backs provider/module/capability validation with the live ("dynamic")
// provider catalog fetched from the API, which changes several times a day. The
// catalog endpoint is public, so this runs regardless of whether a project is
// configured. If the fetch fails (e.g. offline), it degrades gracefully to the
// catalog embedded in the connectors library.
func catalogOption(ctx context.Context) ampyaml.Option {
catProvider, err := validate.NewCatalogProvider(ctx)
if err != nil {
logger.Debugf("Unable to fetch the live provider catalog, "+
"falling back to the embedded catalog: %v", err)

return ampyaml.WithCatalogProvider(catalog.NewDefaultCatalogProvider())
}

return ampyaml.WithCatalogProvider(catProvider)
}

// apiCheckerOptions wires the destination and provider-app checkers to the Ampersand
// API. If no project is configured these checks are skipped, and the validator falls
// back to emitting reminder warnings instead of hard errors. Failures fetching either
// list are logged (debug) and treated as "checker unavailable" rather than fatal so
// that offline/schema validation still succeeds.
func apiCheckerOptions(ctx context.Context) []ampyaml.Option {
projectID := flags.GetProject()
if projectID == "" {
logger.Debugf("No project configured; skipping destination and provider-app checks. " +
"Pass --project to validate these against your Ampersand project.")

return nil
}

apiKey := flags.GetAPIKey()
client := request.NewAPIClient(projectID, &apiKey)

var opts []ampyaml.Option

destChecker, err := validate.NewDestinationChecker(ctx, client)
if err != nil {
logger.Debugf("Unable to fetch destinations for validation, skipping destination checks: %v", err)
} else {
opts = append(opts, ampyaml.WithDestinationChecker(destChecker))
}

appChecker, err := validate.NewProviderAppChecker(ctx, client)
if err != nil {
logger.Debugf("Unable to fetch provider apps for validation, skipping provider-app checks: %v", err)
} else {
opts = append(opts, ampyaml.WithProviderAppChecker(appChecker))
}

return opts
}

func printValidationResult(manifestPath string, result *ampyaml.ValidationResult) {
logger.Infof("Validating: %s", manifestPath)

if result.Valid && len(result.Warnings) == 0 {
logger.Info("✓ Validation passed with no issues!")

return
}

if len(result.Errors) > 0 {
logger.Infof("\n✗ Errors (%d):", len(result.Errors))

for i, issue := range result.Errors {
printValidationIssue(i+1, issue)
}
}

if len(result.Warnings) > 0 {
logger.Infof("\n⚠ Warnings (%d):", len(result.Warnings))

for i, issue := range result.Warnings {
printValidationIssue(i+1, issue)
}
}

logger.Info("")

if result.Valid {
logger.Infof("✓ Validation passed with %d warning(s)", len(result.Warnings))
} else {
logger.Infof("✗ Validation failed with %d error(s) and %d warning(s)",
len(result.Errors), len(result.Warnings))
}
}

func printValidationIssue(num int, issue ampyaml.ValidationIssue) {
logger.Infof("\n %d. [%s] %s", num, issue.Rule, issue.Message)

if issue.Path != "" {
logger.Infof(" Path: %s", issue.Path)
}

if issue.Line > 0 {
if issue.Column > 0 {
logger.Infof(" Location: line %d, column %d", issue.Line, issue.Column)
} else {
logger.Infof(" Location: line %d", issue.Line)
}
}

if issue.Suggestion != "" {
logger.Infof(" Suggestion: %s", issue.Suggestion)
}
}

func init() {
validateCmd.Flags().BoolVar(&validateStrict, "strict", false, "Treat warnings as errors")
validateCmd.Flags().BoolVar(&validateSkipProvider, "skip-provider", false,
"Skip provider-specific validation")
validateCmd.Flags().BoolVar(&validateSkipAsync, "skip-async", false,
"Skip async error-prevention validation")

rootCmd.AddCommand(validateCmd)
}
32 changes: 32 additions & 0 deletions files/zip.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,38 @@ func getZipDir(source string) (string, error) {
return sourceDir, nil
}

// FindManifestFile resolves source to the path of a manifest file to validate.
// An explicit file path is returned as-is regardless of its name; a directory is
// searched for the conventional amp.yaml/amp.yml manifest. It returns
// ErrBadManifest if the source does not resolve to a manifest file.
func FindManifestFile(source string) (string, error) {
info, err := os.Stat(source)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("%w: source %q does not exist", ErrBadManifest, source)
}

return "", fmt.Errorf("%w: stat %q: %w", ErrBadManifest, source, err)
}

// An explicit file path is used as-is, whatever it's named.
if !info.IsDir() {
return source, nil
}

// A directory is searched for the conventional manifest file names.
for _, name := range []string{yamlName, yamlAltName} {
candidate := filepath.Join(source, name)

fi, statErr := os.Stat(candidate)
if statErr == nil && !fi.IsDir() {
return candidate, nil
}
}

return "", fmt.Errorf("%w: no %s or %s found in %q", ErrBadManifest, yamlName, yamlAltName, source)
}

func statYaml() (os.FileInfo, error) {
yamlStat, err := os.Stat(yamlName)
if err != nil { //nolint:nestif
Expand Down
Loading
Loading