Compare commits

..
Author SHA1 Message Date
simon 1febe0f7ff set recipe label for all containers of an app 2026-09-23 15:37:35 +02:00
8 changed files with 100 additions and 226 deletions
-97
View File
@@ -1,97 +0,0 @@
package app
import (
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/abra/pkg/vulncheck"
"github.com/spf13/cobra"
"context"
)
var appVulncheckAliases = i18n.G("vc")
var AppVulncheckCommand = &cobra.Command{
Use: i18n.G("vulncheck [flags] APP"),
Aliases: []string{appVulncheckAliases},
Short: i18n.G("Check for vulnerabilities in images for this app"),
Long: i18n.G("Check for vulnerabilities in images for this app"),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
app := internal.ValidateApp(args)
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
log.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
log.Fatal(err)
}
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
log.Fatal(err)
}
if !deployMeta.IsDeployed {
log.Fatal(i18n.G("%s is not deployed?", app.Name))
}
// log.Debugf("App: %+v", app)
// log.Debugf("Deployed version: %s", deployMeta.Version)
status := stack.GetAllDeployedServices(cl, app.StackName())
ac := &vulncheck.AdvisoryClient{}
ac.Init()
rows := [][]string{}
for _, svc := range status.Services {
imgname := formatter.TrimNs(svc.Spec.Labels["com.docker.stack.image"])
advisories := ac.GetAdvisoriesForImage(imgname)
for _, advisory := range advisories {
rows = append(rows, []string{
advisory.Recipe,
svc.Spec.Name,
imgname,
advisory.ShortDesc,
advisory.Details,
})
}
}
if len(rows) == 0 {
log.Info(i18n.G("No advisories found for %s", app.Name))
return
}
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
headers := []string{
i18n.G("RECIPE"),
i18n.G("SERVICE"),
i18n.G("IMAGE"),
i18n.G("VERSION"),
i18n.G("DETAILS"),
}
table.
Headers(headers...).
Rows(rows...)
if err := formatter.PrintTable(table); err != nil {
log.Fatal(err)
}
upgradeTo := ac.GetRecipeUpgradeTo(deployMeta.Version)
log.Infof(i18n.G("Security advisories found for %s, upgrade recipe from %s to %s"), app.Recipe.Name, deployMeta.Version, upgradeTo)
},
}
-1
View File
@@ -311,7 +311,6 @@ Config:
app.AppVolumeCommand,
app.AppLabelsCommand,
app.AppEnvCommand,
app.AppVulncheckCommand,
)
if err := rootCmd.Execute(); err != nil {
+50 -8
View File
@@ -10,18 +10,60 @@ import (
composetypes "github.com/docker/cli/cli/compose/types"
)
// SetRecipeLabel adds the label 'coop-cloud.${STACK_NAME}.recipe=${RECIPE}' to the app container
// to signal which recipe is connected to the deployed app
func SetRecipeLabel(compose *composetypes.Config, stackName string, recipe string) {
for _, service := range compose.Services {
if service.Name == "app" {
log.Debug(i18n.G("set recipe label 'coop-cloud.%s.recipe' to %s for %s", stackName, recipe, stackName))
labelKey := fmt.Sprintf("coop-cloud.%s.recipe", stackName)
service.Deploy.Labels[labelKey] = recipe
// setLabel writes a label to both the service object and the task template of
// every service of the stack.
//
// The task template labels end up on the containers themselves and are therefore
// visible to container-level tooling (cAdvisor, log shippers, docker events),
// which cannot read service labels at all.
//
// The service object is written as well because it is free: changing
// Spec.Labels updates the service without recreating any task and because
// `docker service ls --filter label=...` matches service labels only.
func setLabel(compose *composetypes.Config, key string, value string) {
for i := range compose.Services {
if compose.Services[i].Deploy.Labels == nil {
compose.Services[i].Deploy.Labels = composetypes.Labels{}
}
if compose.Services[i].Labels == nil {
compose.Services[i].Labels = composetypes.Labels{}
}
compose.Services[i].Deploy.Labels[key] = value
compose.Services[i].Labels[key] = value
}
}
// setAppLabel writes a label to the service object of the app service only.
func setAppLabel(compose *composetypes.Config, key string, value string) {
for i := range compose.Services {
if compose.Services[i].Name != "app" {
continue
}
if compose.Services[i].Deploy.Labels == nil {
compose.Services[i].Deploy.Labels = composetypes.Labels{}
}
compose.Services[i].Deploy.Labels[key] = value
}
}
// SetRecipeLabel adds two distinct labels to signal which recipe is connected
// to the deployed app:
// - 'coop-cloud.${STACK_NAME}.recipe=${RECIPE}' on the app service object,
// unchanged, as read back by GetLabel
// - 'coop-cloud.recipe=${RECIPE}' on every service of the stack, so that
// tooling can attribute any container to a recipe without knowing the
// stack name up front
func SetRecipeLabel(compose *composetypes.Config, stackName string, recipe string) {
log.Debug(i18n.G("set recipe labels 'coop-cloud.%s.recipe' and 'coop-cloud.recipe' to %s for %s", stackName, recipe, stackName))
setAppLabel(compose, fmt.Sprintf("coop-cloud.%s.recipe", stackName), recipe)
setLabel(compose, "coop-cloud.recipe", recipe)
}
// SetChaosLabel adds the label 'coop-cloud.${STACK_NAME}.chaos=true/false' to the app container
// to signal if the app is deployed in chaos mode
func SetChaosLabel(compose *composetypes.Config, stackName string, chaos bool) {
+41
View File
@@ -8,6 +8,7 @@ import (
testPkg "coopcloud.tech/abra/pkg/test"
stack "coopcloud.tech/abra/pkg/upstream/stack"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/stretchr/testify/assert"
)
@@ -61,3 +62,43 @@ func TestGetTimeoutFromLabel(t *testing.T) {
assert.Equal(t, timeout, test.expectedTimeout)
}
}
func TestSetRecipeLabel(t *testing.T) {
// the app service brings labels along on both levels, the db service
// has none at all, so the pre-existing and the nil map case are covered
compose := &composetypes.Config{
Services: []composetypes.ServiceConfig{
{
Name: "app",
Labels: composetypes.Labels{"example.container.label": "keep me"},
Deploy: composetypes.DeployConfig{
Labels: composetypes.Labels{"coop-cloud.backupbot.enabled": "true"},
},
},
{Name: "db"},
},
}
appPkg.SetRecipeLabel(compose, "test_example_com", "test-recipe")
services := make(map[string]composetypes.ServiceConfig)
for _, service := range compose.Services {
services[service.Name] = service
}
// the stack scoped label stays on the app service object only
assert.Equal(t, "test-recipe",
services["app"].Deploy.Labels["coop-cloud.test_example_com.recipe"])
assert.NotContains(t, services["db"].Deploy.Labels,
"coop-cloud.test_example_com.recipe")
// the static label reaches every container of the stack
for name, service := range services {
assert.Equal(t, "test-recipe", service.Labels["coop-cloud.recipe"], name)
assert.Equal(t, "test-recipe", service.Deploy.Labels["coop-cloud.recipe"], name)
}
// labels the recipe brought along are left alone
assert.Equal(t, "keep me", services["app"].Labels["example.container.label"])
assert.Equal(t, "true", services["app"].Deploy.Labels["coop-cloud.backupbot.enabled"])
}
-18
View File
@@ -34,24 +34,6 @@ func SmallSHA(hash string) string {
return hash[:8]
}
// TrimNs strips any repository namespace/org and digest from an image string.
//
// Examples:
//
// "n8nio/n8n:1.81.2" -> "n8n:1.81.2"
// "nginx:1.29.0@sha256:3ab4ed..." -> "nginx:1.29.0"
// "docker.io/library/nginx:latest" -> "nginx:latest"
func TrimNs(image string) string {
// 1. Remove digest suffix if present (@sha256:...)
if idx := strings.Index(image, "@"); idx != -1 {
image = image[:idx]
}
// 2. Remove registry domain and org/namespace path (keep only the last element)
parts := strings.Split(image, "/")
return parts[len(parts)-1]
}
// RemoveSha remove image sha from a string that are added in some docker outputs
func RemoveSha(str string) string {
return strings.Split(str, "@")[0]
+9 -9
View File
@@ -3047,7 +3047,7 @@ msgstr ""
msgid "generated secrets %s shown again, please take note of them %s"
msgstr ""
#: ./pkg/app/compose.go:63
#: ./pkg/app/compose.go:105
#, c-format
msgid "get label '%s'"
msgstr ""
@@ -3695,7 +3695,7 @@ msgstr ""
msgid "no %s exists, skipping reading gitignore paths"
msgstr ""
#: ./pkg/app/compose.go:69
#: ./pkg/app/compose.go:111
#, c-format
msgid "no %s label found for %s"
msgstr ""
@@ -4746,24 +4746,24 @@ msgstr ""
msgid "set 'main' as the default branch"
msgstr ""
#: ./pkg/app/compose.go:30
#: ./pkg/app/compose.go:72
#, c-format
msgid "set label 'coop-cloud.%s.chaos' to %v for %s"
msgstr ""
#: ./pkg/app/compose.go:41
#: ./pkg/app/compose.go:83
#, c-format
msgid "set label 'coop-cloud.%s.chaos-version' to %v for %s"
msgstr ""
#: ./pkg/app/compose.go:51
#: ./pkg/app/compose.go:93
#, c-format
msgid "set label 'coop-cloud.%s.version' to %v for %s"
msgstr ""
#: ./pkg/app/compose.go:18
#: ./pkg/app/compose.go:61
#, c-format
msgid "set recipe label 'coop-cloud.%s.recipe' to %s for %s"
msgid "set recipe labels 'coop-cloud.%s.recipe' and 'coop-cloud.recipe' to %s for %s"
msgstr ""
#: ./pkg/git/init.go:60
@@ -5064,7 +5064,7 @@ msgstr ""
msgid "timed out on undeploy (timeout=%v sec)"
msgstr ""
#: ./pkg/app/compose.go:80
#: ./pkg/app/compose.go:122
#, c-format
msgid "timeout label: %s"
msgstr ""
@@ -5172,7 +5172,7 @@ msgstr ""
msgid "unable to continue, input required for initial version"
msgstr ""
#: ./pkg/app/compose.go:85
#: ./pkg/app/compose.go:127
#, c-format
msgid "unable to convert timeout label %s to int: %s"
msgstr ""
-92
View File
@@ -1,92 +0,0 @@
package vulncheck
import (
"encoding/json"
"os"
"path"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/git"
)
type Advisory struct {
Id string `json:"ccsa_id"`
Recipe string `json:"recipe"`
AdvisoryDate string `json:"date"`
RecipeVersions []string `json:"versions"`
RecipesUpgradeTo []string `json:"upgrade_to"`
Images []string `json:"images"`
ShortDesc string `json:"short"`
Details string `json:"description"`
Url string `json:"url"`
}
type AdvisoryClient struct {
Advisories []Advisory
}
func (a *AdvisoryClient) Init() {
dir := path.Join(config.ABRA_DIR, "security-advisories")
if _, err := os.Stat(dir); os.IsNotExist(err) {
log.Infof("Syncing abra security advisories...")
err := git.Clone(dir, "https://git.coopcloud.tech/sixsmith/security-advisories.git")
if err != nil {
log.Debugf("Error cloning security advisories repo: ", err)
log.Fatalf("Error cloning security advisories repo: ", err)
}
}
afp := path.Join(dir, "CCSA.json")
err := a.loadAdvisoriesFromFile(afp)
if err != nil {
log.Fatalf("Error loading advisories: %v\n", err)
}
}
func (a *AdvisoryClient) loadAdvisoriesFromFile(filePath string) error {
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
var advisories []Advisory
decoder := json.NewDecoder(file)
err = decoder.Decode(&advisories)
if err != nil {
return err
}
a.Advisories = advisories
return nil
}
func (a *AdvisoryClient) GetAdvisoriesForImage(image string) []Advisory {
var relevantAdvisories []Advisory
for _, advisory := range a.Advisories {
for _, img := range advisory.Images {
if formatter.TrimNs(image) == formatter.TrimNs(img) {
relevantAdvisories = append(relevantAdvisories, advisory)
break
}
}
}
return relevantAdvisories
}
func (a *AdvisoryClient) GetRecipeUpgradeTo(recipeVer string) string {
for _, advisory := range a.Advisories {
for _, ver := range advisory.RecipeVersions {
if recipeVer == ver {
if len(advisory.RecipesUpgradeTo) > 0 {
return advisory.RecipesUpgradeTo[0]
}
}
}
}
return ""
}
-1
View File
@@ -1 +0,0 @@
package vulncheck