Developer-Dashboard

 view release on metacpan or  search on metacpan

t/10-extension-action-docker.t  view on Meta::CPAN

use strict;
use warnings;

use Cwd qw(abs_path);
use File::Path qw(make_path);
use File::Spec;
use File::Temp qw(tempdir);
use Test::More;
use Time::HiRes qw(sleep);
use URI::Escape qw(uri_escape);

use lib 'lib';

use Developer::Dashboard::ActionRunner;
use Developer::Dashboard::Auth;
use Developer::Dashboard::Config;
use Developer::Dashboard::DockerCompose;
use Developer::Dashboard::FileRegistry;
use Developer::Dashboard::PageDocument;
use Developer::Dashboard::PageResolver;
use Developer::Dashboard::PageStore;
use Developer::Dashboard::PathRegistry;
use Developer::Dashboard::SessionStore;
use Developer::Dashboard::Web::App;

sub _portable_path {
    my ($path) = @_;
    return undef if !defined $path;
    my $resolved = eval { abs_path($path) };
    return defined $resolved && $resolved ne '' ? $resolved : $path;
}

sub is_same_path {
    my ( $got, $expected, $label ) = @_;
    is( _portable_path($got), _portable_path($expected), $label );
}

my $home = tempdir(CLEANUP => 1);
local $ENV{HOME} = $home;
local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS};
local $ENV{DEVELOPER_DASHBOARD_CONFIGS};
local $ENV{DEVELOPER_DASHBOARD_CHECKERS};
chdir $home or die "Unable to chdir to $home: $!";

my $repo = File::Spec->catdir( $home, 'projects', 'demo-app' );
make_path( File::Spec->catdir( $repo, '.git' ) );
open my $compose_fh, '>', File::Spec->catfile( $repo, 'compose.yaml' ) or die $!;
print {$compose_fh} "services:\n  app:\n    image: perl:latest\n";
close $compose_fh;
open my $override_fh, '>', File::Spec->catfile( $repo, 'compose.dev.yaml' ) or die $!;
print {$override_fh} "services:\n  app:\n    environment:\n      MODE: dev\n";
close $override_fh;
open my $repo_cfg, '>', File::Spec->catfile( $repo, '.developer-dashboard.json' ) or die $!;
print {$repo_cfg} <<'JSON';
{
  "docker": {
    "project_overlays": ["compose.project.yaml"],
    "services": {
      "worker": {
        "files": ["compose.worker.yaml"]
      }
    },
    "addons": {
      "mailhog": {
        "files": ["compose.mailhog.yaml"],
        "env": { "MAILHOG_ENABLED": "1" }
      }
    },
    "modes": {
      "dev": {
        "files": ["compose.dev.yaml"],
        "env": { "APP_MODE": "dev" }
      }
    }
  },
  "path_aliases": {
    "repo_alias": "~/projects/demo-app",
    "shared_alias": "~/projects/demo-app"
  },
  "providers": [
    {
      "id": "repo-provider",
      "title": "Repo Provider",
      "body": "from config provider"
    },
    {
      "id": "shared-provider",
      "page": {
        "id": "shared-provider",
        "title": "Shared Provider",
        "layout": { "body": "from config page provider" },
        "actions": [
          { "id": "show_state", "label": "Show State", "kind": "builtin", "builtin": "page.state", "safe": 1 }
        ]
      }
    }
  ]
}
JSON
close $repo_cfg;
open my $addon_fh, '>', File::Spec->catfile( $repo, 'compose.mailhog.yaml' ) or die $!;
print {$addon_fh} "services:\n  mailhog:\n    image: mailhog/mailhog\n";
close $addon_fh;
open my $project_overlay_fh, '>', File::Spec->catfile( $repo, 'compose.project.yaml' ) or die $!;
print {$project_overlay_fh} "services:\n  app:\n    environment:\n      PROJECT_LAYER: 1\n";
close $project_overlay_fh;
open my $service_overlay_fh, '>', File::Spec->catfile( $repo, 'compose.worker.yaml' ) or die $!;
print {$service_overlay_fh} "services:\n  worker:\n    image: perl:latest\n";
close $service_overlay_fh;
my $global_docker_root = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker', 'green' );
make_path($global_docker_root);
open my $global_green_fh, '>', File::Spec->catfile( $global_docker_root, 'compose.yml' ) or die $!;
print {$global_green_fh} "services:\n  green:\n    extra_hosts:\n      - host.docker.internal:host-gateway\n";
close $global_green_fh;
open my $global_green_dev_fh, '>', File::Spec->catfile( $global_docker_root, 'development.compose.yml' ) or die $!;
print {$global_green_dev_fh} "services:\n  green:\n    environment:\n      GREEN_DEV: 1\n";
close $global_green_dev_fh;
my $global_blue_root = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker', 'blue' );
make_path($global_blue_root);
open my $global_blue_fh, '>', File::Spec->catfile( $global_blue_root, 'compose.yml' ) or die $!;
print {$global_blue_fh} "services:\n  blue:\n    image: alpine\n";
close $global_blue_fh;
open my $global_blue_disabled_fh, '>', File::Spec->catfile( $global_blue_root, 'disabled.yml' ) or die $!;
close $global_blue_disabled_fh;
my $global_purple_root = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker', 'purple' );
make_path($global_purple_root);
open my $global_purple_fh, '>', File::Spec->catfile( $global_purple_root, 'compose.yml' ) or die $!;
print {$global_purple_fh} "services:\n  purple:\n    image: alpine\n";
close $global_purple_fh;
my $skill_orange_root = File::Spec->catdir( $home, '.developer-dashboard', 'skills', 'alpha-skill', 'config', 'docker', 'orange' );
make_path($skill_orange_root);
open my $skill_orange_fh, '>', File::Spec->catfile( $skill_orange_root, 'compose.yml' ) or die $!;
print {$skill_orange_fh} "services:\n  orange:\n    image: alpine\n";
close $skill_orange_fh;
open my $skill_orange_env_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'alpha-skill', '.env' ) or die $!;
print {$skill_orange_env_fh} "ORANGE_SKILL_ENV=orange-skill\nSKILL_ENV_REF=\${DOCKER_SKILL_BASE:-fallback}\n";
close $skill_orange_env_fh;
my $skill_green_root = File::Spec->catdir( $home, '.developer-dashboard', 'skills', 'beta-skill', 'config', 'docker', 'green' );
make_path($skill_green_root);
open my $skill_green_fh, '>', File::Spec->catfile( $skill_green_root, 'compose.yml' ) or die $!;
print {$skill_green_fh} "services:\n  green:\n    environment:\n      GREEN_DEV: skill\n";
close $skill_green_fh;
open my $skill_green_env_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'beta-skill', '.env' ) or die $!;
print {$skill_green_env_fh} "DISABLED_SKILL_ENV=beta-skill\n";
close $skill_green_env_fh;
open my $skill_green_disabled_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'beta-skill', '.disabled' ) or die $!;
close $skill_green_disabled_fh;
my $nested_skill_leaf_root = File::Spec->catdir(
    $home, '.developer-dashboard', 'skills', 'foo', 'skills', 'bar', 'skills', 'zzz'
);
my $nested_skill_compose_root = File::Spec->catdir( $nested_skill_leaf_root, 'config', 'docker', 'zzz' );
make_path($nested_skill_compose_root);
open my $nested_skill_compose_fh, '>', File::Spec->catfile( $nested_skill_compose_root, 'compose.yml' ) or die $!;
print {$nested_skill_compose_fh} "services:\n  zzz:\n    image: alpine\n";
close $nested_skill_compose_fh;
open my $nested_skill_root_env_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'foo', '.env' ) or die $!;
print {$nested_skill_root_env_fh} "VERSION=foo\nSHARED_CHAIN=root\n";
close $nested_skill_root_env_fh;
open my $nested_skill_parent_env_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'foo', 'skills', 'bar', '.env' ) or die $!;
print {$nested_skill_parent_env_fh} "VERSION=bar\nSHARED_CHAIN=bar\n";
close $nested_skill_parent_env_fh;
open my $nested_skill_leaf_env_fh, '>', File::Spec->catfile( $nested_skill_leaf_root, '.env' ) or die $!;
print {$nested_skill_leaf_env_fh} "VERSION=zzz\nSHARED_CHAIN=zzz\n";
close $nested_skill_leaf_env_fh;
my $local_docker_green_root = File::Spec->catdir( $repo, '.developer-dashboard', 'config', 'docker', 'green' );
make_path($local_docker_green_root);
open my $local_green_dev_fh, '>', File::Spec->catfile( $local_docker_green_root, 'development.compose.yml' ) or die $!;
print {$local_green_dev_fh} "services:\n  green:\n    environment:\n      GREEN_DEV: local\n";
close $local_green_dev_fh;

t/10-extension-action-docker.t  view on Meta::CPAN

    );
};
like( $@, qr/Unsupported builtin action/, 'unsupported builtin actions are rejected' );

my $transient_allowed = Developer::Dashboard::PageDocument->new(
    title       => 'Transient Allowed',
    actions     => [
        { id => 'run', label => 'Run', kind => 'command', command => 'printf allowed' },
    ],
    permissions => {
        allow_untrusted_actions => 1,
        trusted_actions         => ['run'],
    },
);
my $allowed_page = $transient_allowed;
my $allowed_result = $actions->run_page_action(
    action => { id => 'run', label => 'Run', kind => 'command', command => 'printf allowed' },
    page   => $allowed_page,
    source => 'transient',
);
like( $allowed_result->{stdout}, qr/allowed/, 'transient encoded page can opt in specific trusted actions' );

{
    my $old = Cwd::getcwd();
    chdir $repo or die $!;
    my $docker = Developer::Dashboard::DockerCompose->new(
        config  => $config,
        paths   => $paths,
    );
    local $ENV{DOCKER_SKILL_BASE} = 'skill-base';
    local $ENV{DD_TEST_DOCKER_ROOT} = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker' );
    is(
        $docker->_expand_env_path('${DD_TEST_DOCKER_ROOT}/green/compose.yml'),
        File::Spec->catfile( $ENV{DD_TEST_DOCKER_ROOT}, 'green', 'compose.yml' ),
        'docker compose resolver expands defined braced environment variables in configured compose paths',
    );
    is(
        $docker->_expand_env_path('${DD_TEST_DOCKER_MISSING}green/compose.yml'),
        'green/compose.yml',
        'docker compose resolver collapses undefined braced environment variables in configured compose paths',
    );
    is(
        $docker->_expand_env_path('$DD_TEST_DOCKER_ROOT/green/compose.yml'),
        File::Spec->catfile( $ENV{DD_TEST_DOCKER_ROOT}, 'green', 'compose.yml' ),
        'docker compose resolver expands defined bare environment variables in configured compose paths',
    );
    is(
        $docker->_expand_env_path('$DD_TEST_DOCKER_MISSING' . 'green/compose.yml'),
        '/compose.yml',
        'docker compose resolver collapses undefined bare environment variables in configured compose paths',
    );
    my $resolved = $docker->resolve(
        addons => [ 'mailhog' ],
        args   => [ 'config', 'green' ],
        modes  => ['dev'],
        services => [ 'worker', 'orange' ],
    );
    chdir $old or die $!;
    is_same_path( $resolved->{project_root}, $repo, 'docker compose resolver uses current project root' );
    ok( grep( { /compose\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes discovered base file' );
    ok( grep( { /compose\.project\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes project overlay' );
    ok( grep( { /compose\.worker\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes service overlay' );
    ok( grep( { /compose\.dev\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes mode overlay' );
    ok( grep( { /compose\.mailhog\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes config addon overlay' );
    ok( grep( { /green\/development\.compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes isolated development compose files automatically for selected services' );
    ok( !grep( { /skills\/beta-skill\/config\/docker\/green\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver excludes docker roots contributed by disabled skills' );
    is( $resolved->{env}{APP_MODE}, 'dev', 'docker compose resolver merges mode env' );
    is_same_path(
        $resolved->{env}{alpha_skill_DDDC},
        File::Spec->catdir( $home, '.developer-dashboard', 'skills', 'alpha-skill', 'config', 'docker' ),
        'docker compose resolver exports one skill-specific DDDC variable for each participating skill docker root',
    );
    is( $resolved->{env}{ORANGE_SKILL_ENV}, 'orange-skill', 'docker compose resolver loads .env from participating skill docker roots' );
    is( $resolved->{env}{SKILL_ENV_REF}, 'skill-base', 'docker compose resolver expands participating skill .env values against the current process environment' );
    ok( !exists $resolved->{env}{DISABLED_SKILL_ENV}, 'docker compose resolver skips .env from disabled skill docker roots' );
    ok( grep( { /skills\/alpha-skill\/\.env$/ } @{ $resolved->{env_files} } ), 'docker compose resolver reports participating skill .env files in dry-run data' );
    is_same_path( $resolved->{env}{DDDC}, File::Spec->catdir( $repo, '.developer-dashboard', 'config', 'docker' ), 'docker compose resolver exports DDDC as the effective project-local docker config root' );
    is( $resolved->{env}{MAILHOG_ENABLED}, '1', 'docker compose resolver merges addon env' );
    is_deeply( [ @{ $resolved->{command} }[0,1] ], [ 'docker', 'compose' ], 'docker compose resolver produces docker compose command' );
    is_deeply( $resolved->{precedence}, [ qw(base project service addon mode) ], 'docker compose resolver exposes overlay precedence' );
    is_same_path(
        ( grep { /green\/development\.compose\.yml$/ } @{ $resolved->{files} } )[-1],
        File::Spec->catfile( $repo, '.developer-dashboard', 'config', 'docker', 'green', 'development.compose.yml' ),
        'docker compose resolver leaves the deepest project-local config/docker service folder as the last overriding development compose layer',
    );
    ok( grep( { $_ eq 'green' } @{ $resolved->{services} } ), 'docker compose resolver infers service names from passthrough docker compose args' );
    is( $resolved->{command}[-1], 'green', 'docker compose resolver preserves passthrough docker compose service args' );
}

{
    my $old = Cwd::getcwd();
    chdir $repo or die $!;
    my $docker = Developer::Dashboard::DockerCompose->new(
        config  => $config,
        paths   => $paths,
    );
    local $ENV{ORANGE_SKILL_ENV};
    my $resolved = $docker->resolve(
        args => ['config'],
    );
    chdir $old or die $!;
    ok( grep( { $_ eq 'green' } @{ $resolved->{services} } ), 'docker compose resolver auto-loads isolated services by default when no service is specified' );
    ok( grep( { $_ eq 'orange' } @{ $resolved->{services} } ), 'docker compose resolver auto-loads isolated services contributed by installed skills' );
    ok( grep( { $_ eq 'purple' } @{ $resolved->{services} } ), 'docker compose resolver auto-loads isolated services without requiring activation markers' );
    ok( !grep( { $_ eq 'blue' } @{ $resolved->{services} } ), 'docker compose resolver skips isolated services marked disabled' );
    ok( grep( { /green\/development\.compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes isolated development compose files during plain docker compose passthrough' );
    ok( !grep( { /skills\/beta-skill\/config\/docker\/green\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose passthrough excludes compose roots contributed by disabled skills' );
    ok( grep( { /skills\/alpha-skill\/config\/docker\/orange\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes skill docker compose roots during plain docker compose passthrough' );
    ok( grep( { /purple\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes non-disabled isolated compose folders during plain docker compose passthrough' );
    ok( !grep( { /blue\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver does not include disabled isolated compose folders' );
    is_same_path(
        $resolved->{env}{alpha_skill_DDDC},
        File::Spec->catdir( $home, '.developer-dashboard', 'skills', 'alpha-skill', 'config', 'docker' ),
        'docker compose resolver auto-loads one skill-specific DDDC variable for auto-discovered skill services',
    );
    is( $resolved->{env}{ORANGE_SKILL_ENV}, 'orange-skill', 'docker compose resolver auto-loads participating skill .env values alongside auto-discovered services' );
    ok( !exists $resolved->{env}{DISABLED_SKILL_ENV}, 'docker compose resolver still skips disabled skill .env values during auto-discovery' );
    ok( !defined $ENV{ORANGE_SKILL_ENV}, 'docker compose resolver does not leak participating skill .env values into the caller environment' );
    is( $resolved->{command}[-1], 'config', 'docker compose resolver preserves passthrough config invocation with active auto-discovery' );
}

{
    my $old = Cwd::getcwd();
    chdir $repo or die $!;
    my $docker = Developer::Dashboard::DockerCompose->new(
        config  => $config,
        paths   => $paths,
    );
    my $disabled = $docker->disable_service( service => 'green' );
    my $enabled  = $docker->enable_service( service => 'green' );
    chdir $old or die $!;
    is_same_path(
        $disabled->{marker},
        File::Spec->catfile( $repo, '.developer-dashboard', 'config', 'docker', 'green', 'disabled.yml' ),
        'docker compose disable writes the project-local marker under config/docker',
    );
    is_same_path(
        $enabled->{marker},
        File::Spec->catfile( $repo, '.developer-dashboard', 'config', 'docker', 'green', 'disabled.yml' ),
        'docker compose enable removes the project-local marker under config/docker',



( run in 0.916 second using v1.01-cache-2.11-cpan-df04353d9ac )